feat(render): Vulkan campaign V11 step 2 — delete the OpenGL backend

Vulkan is the sole, user-signed-off backend (V10 landed) and step 1
already removed ImGui/Studio/DevTools. This step deletes the GL
rendering backend itself: every Gpu/Gl/** implementation, the Wb
ManagedGL*/GLHelpers/GLSLShader/GLStateScope/RenderStateCache/
BindlessSupport family, Shader/ShaderProgramConstruction/SamplerCache,
RenderBootstrap, and RenderFrameGlStateController.

GameWindow.cs's Run()/CreateGraphics()/CreateBackbufferReader()/
OnLoad() collapse to their Vulkan-only arm; GameWindowGraphics loses
its OpenGlGameWindowGraphics subclass. RuntimeOptions.RenderBackend and
RenderBackendKind (incl. the Gl member of GpuBackendKind) are gone —
there is nothing left to select between. The five world-draw dual-arm
renderers (WbDrawDispatcher, EnvCellRenderer, TerrainModernRenderer,
ParticleRenderer, SkyRenderer) and the composition roots
(WorldRenderComposition, HostInputCameraComposition,
LivePresentationComposition, FrameRootComposition) collapse to their
RHI-only arm. GL-only diagnostic properties with a live external reader
(DynamicBufferCount and friends) simplify to a documented `=> 0`/no-op
rather than disappearing, since the reader is out of this commit's
scope.

A few GL-flavored mechanisms turned out to be backend-neutral once
isolated: GlConstructionCleanupLedger is renamed
ResourceConstructionCleanupLedger (exception-chain walking has nothing
to do with GL), and GlfwNativePlatformProbe moved out of the otherwise
GL-only GraphicalCapabilityRecord.cs into
GraphicalWindowBackendSelection.cs before the rest of that file was
deleted.

Test files with no surviving subject are deleted outright
(GraphicalCapabilityRequirementsTests, ShaderProgramConstructionTests,
PortalDepthShaderParityTests, TextureCacheBindlessTests,
TextRendererFailureSafetyTests, ClipFrameUploadTests, every
Gpu/Gl/*Tests, GlTextureOwnershipTests, RenderFrameGlStateControllerTests);
others get their dead GL-only members trimmed while their live
assertions stay (ClipFrameLayoutTests' MeshClipSsboBinding check now
reads GpuBindingModel.StorageClipRegions, the same binding index under
its new backend-neutral name; GpuResourceRetirementTransactionTests
drops its OpenGLGraphicsDevice-subclassing test double and the two GL
queue tests it existed for). EnvCellRendererTests' construction helper
now builds a real ObjectMeshManager via VulkanMeshPipelineDevice
instead of passing null through a null-forgiving operator, since the
RHI constructor never tolerated a null mesh manager and the old GL
constructor (which did) is gone.

Deferred to the next two steps, deliberately not touched here: the
Silk.NET.OpenGL/.Extensions.ARB package references, IMeshPipelineDevice.Gl
(WbMeshAdapter's GL? threading stays in place), Chorizite.Core's stale
csproj comment (the package itself is still load-bearing —
TextureFormat and friends are used well beyond the deleted
ManagedGLUniformBuffer), and the CI/gate scripts.

Build: `dotnet build AcDream.slnx -c Release` — 0 warnings, 0 errors.
Tests: full-solution `dotnet test` green across every project
(App.Tests 3937/3940 + 3 skips, Core.Tests 3296/3298 + 2 skips, all
others 100%); the 2 App.Tests names that flake under full-suite
parallel execution (#250-family, documented pre-existing) pass in
isolation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-29 02:19:53 +02:00
parent b70b9832ff
commit 8a7a0837e1
121 changed files with 1243 additions and 19840 deletions

View file

@ -16,7 +16,6 @@ using AcDream.Runtime;
using AcDream.Runtime.Gameplay;
using DatReaderWriter;
using Silk.NET.Input;
using Silk.NET.OpenGL;
namespace AcDream.App.Composition;

View file

@ -15,7 +15,6 @@ using AcDream.Core.Physics;
using AcDream.Core.Selection;
using AcDream.Core.World;
using Silk.NET.Input;
using Silk.NET.OpenGL;
using Silk.NET.Windowing;
namespace AcDream.App.Composition;
@ -236,20 +235,15 @@ internal sealed class FrameRootCompositionPhase
{
FrameRootDependencies d = _dependencies;
WorldRenderFoundation foundation = world.Foundation;
// Campaign V slice V6h: the frame root is the one seam that genuinely
// forks. On GL every world renderer exists and the render graph is
// unchanged. On Vulkan none of them does, so the graph is the clear pass,
// the private-presentation phase, and the retained UI inside it — the
// client's own frame, drawn entirely through the RHI.
GL? gl = d.Graphics.Gl;
// Campaign V slice V6h: the frame root's raw-GL render graph fork was
// deleted at slice V11. The graph is the clear pass, the private-
// presentation phase, and the retained UI inside it — the client's
// own frame, drawn entirely through the RHI.
var teleportRenderState =
new LocalPlayerTeleportRenderStateSource(session.LocalTeleport);
var renderLoginState = new RenderLoginStateSource(
d.Options.LiveMode,
d.PlayerMode);
RenderFrameGlStateController? renderFrameGlState = gl is null
? null
: new RenderFrameGlStateController(new SilkRenderFrameGlStateApi(gl));
// Campaign V slice V6i-3: on Vulkan the frame's clear is a load op of the
// world pass rather than a pass of its own, so the two phases share this
// one value. See VulkanWorldScenePhase for why the merge is required
@ -268,18 +262,8 @@ internal sealed class FrameRootCompositionPhase
live.ParticleRenderer,
d.FrameProfiler,
d.FrameDiagnosticsEnabled);
IRenderFrameClearPhase clearPhase = gl is not null
? new RuntimeRenderFrameClearPhase(
gl,
d.WorldTime,
d.Weather,
teleportRenderState,
d.ParticleVisibility,
host.WorldRenderDiagnostics
?? throw new InvalidOperationException(
"The GL frame root requires the GL state tripwire."),
renderFrameGlState!)
: new AcDream.App.Rendering.Gpu.Vk.VulkanRenderFrameClearPhase(
IRenderFrameClearPhase clearPhase =
new AcDream.App.Rendering.Gpu.Vk.VulkanRenderFrameClearPhase(
d.WorldTime,
d.Weather,
teleportRenderState,
@ -335,27 +319,17 @@ internal sealed class FrameRootCompositionPhase
?? new WorldRenderDiagnostics(
NullRenderGlStateReader.Instance,
d.RenderDiagnosticLog);
IRenderFrameGlState worldFrameGlState =
(IRenderFrameGlState?)renderFrameGlState
?? NullRenderFrameGlState.Instance;
IRenderFrameGlState worldFrameGlState = NullRenderFrameGlState.Instance;
IWorldPassScope? worldPassScope = d.Graphics.WorldPassScope;
var worldFramebufferSource =
new SilkRetailPViewFramebufferSource(d.Window);
IWorldPassSurface worldPassSurface = gl is not null
? new GlWorldPassSurface(
gl,
live.ClipFrame,
worldFramebufferSource,
live.DrawDispatcher!,
live.EnvCellRenderer!,
foundation.Terrain)
: new RhiWorldPassSurface(
worldPassScope
?? throw new InvalidOperationException(
"A backend without a GL context must publish a world pass scope."),
host.GpuFrameLifetime,
live.ClipFrame,
worldFramebufferSource);
IWorldPassSurface worldPassSurface = new RhiWorldPassSurface(
worldPassScope
?? throw new InvalidOperationException(
"The graphics backend must publish a world pass scope."),
host.GpuFrameLifetime,
live.ClipFrame,
worldFramebufferSource);
var worldFrameEnvironment =
new RuntimeWorldFrameEnvironmentPreparation(
d.Options,
@ -438,7 +412,7 @@ internal sealed class FrameRootCompositionPhase
// V11, so nothing is lost — composing it would throw on the
// first wireframe frame rather than silently misdraw (plan
// §5.5.14 item 7).
gl is not null ? foundation.DebugLines : null,
null,
d.PhysicsEngine,
d.PlayerMode,
d.PlayerController,
@ -488,21 +462,17 @@ internal sealed class FrameRootCompositionPhase
d.RenderRange,
worldSceneDiagnostics,
live.WorldAvailability);
if (gl is null)
{
// On Vulkan the world renderer runs INSIDE the frame's one
// backbuffer pass, which this phase opens, publishes on the
// scope, and closes.
worldSceneRenderer =
new AcDream.App.Rendering.Gpu.Vk.VulkanWorldScenePhase(
host.GpuFrameLifetime,
vulkanClear,
() => d.Graphics.Vulkan?.SampleCount ?? 1,
(d.Graphics as VulkanGameWindowGraphics)?.WorldPassScopeCore
?? throw new InvalidOperationException(
"The Vulkan world phase requires the Vulkan graphics handle."),
worldSceneRenderer);
}
// The world renderer runs INSIDE the frame's one backbuffer pass,
// which this phase opens, publishes on the scope, and closes.
worldSceneRenderer =
new AcDream.App.Rendering.Gpu.Vk.VulkanWorldScenePhase(
host.GpuFrameLifetime,
vulkanClear,
() => d.Graphics.Vulkan?.SampleCount ?? 1,
(d.Graphics as VulkanGameWindowGraphics)?.WorldPassScopeCore
?? throw new InvalidOperationException(
"The Vulkan world phase requires the Vulkan graphics handle."),
worldSceneRenderer);
}
Fault(FrameRootCompositionPoint.WorldRendererCreated);
bindings = new FrameRootRuntimeBindings();
@ -591,17 +561,15 @@ internal sealed class FrameRootCompositionPhase
?? NullRenderFramePostDiagnosticsPhase.Instance;
var renderFrame = new RenderFrameOrchestrator(
host.GpuFrameLifetime,
gl is not null
? new FrameProfilerGpuMeasurement(d.FrameProfiler, gl)
// Campaign V slice V8: the Vulkan arm measures the same bracket
// through its own timestamp scope. It stayed on the null adapter
// from V6h until V8, which meant no [frame-prof] line existed on
// Vulkan at all — see VulkanFrameGpuMeasurement.
: d.Graphics.Vulkan is { } vulkanGraphics
? new AcDream.App.Rendering.Gpu.Vk.VulkanFrameGpuMeasurement(
d.FrameProfiler,
vulkanGraphics.Device)
: AcDream.App.Rendering.Gpu.Vk.NullRenderFrameGpuMeasurement.Instance,
// Campaign V slice V8: the Vulkan arm measures the frame bracket
// through its own timestamp scope — see VulkanFrameGpuMeasurement.
// The raw-GL adapter (FrameProfilerGpuMeasurement) was deleted at
// slice V11.
d.Graphics.Vulkan is { } vulkanGraphics
? new AcDream.App.Rendering.Gpu.Vk.VulkanFrameGpuMeasurement(
d.FrameProfiler,
vulkanGraphics.Device)
: AcDream.App.Rendering.Gpu.Vk.NullRenderFrameGpuMeasurement.Instance,
framePreparation,
worldSceneRenderer,
privatePresentation,

View file

@ -1,79 +1,32 @@
using AcDream.App.Rendering.Gpu.Vk;
using Silk.NET.OpenGL;
namespace AcDream.App.Composition;
/// <summary>
/// Campaign V slice V6h: the graphics ownership that platform acquisition
/// publishes, once per backend.
/// The graphics ownership that platform acquisition publishes.
///
/// <para><see cref="GameWindowPlatformAcquisition"/> was already generic over its
/// graphics type; only the call sites pinned <c>GL</c>. This class is what they
/// pin instead, so one composition pipeline drives both backends and the fork
/// lives at the handful of construction sites that genuinely differ rather than
/// in a second copy of the startup topology.</para>
///
/// <para>It is deliberately NOT a capability abstraction. Nothing dispatches on
/// it per frame; phases that still speak raw GL ask for the context by name and
/// take their Vulkan arm when it is absent. The GL arm is deleted at slice V11
/// and this class with it.</para>
/// <para>Vulkan is the only backend as of Campaign V slice V11: the raw-GL
/// implementation (<c>OpenGlGameWindowGraphics</c>) and the members that only
/// existed to branch on it (<c>Backend</c>, <c>Gl</c>, <c>RequireGl</c>) were
/// deleted along with it. This class stays as the seam
/// <see cref="GameWindowPlatformAcquisition"/> and the composition phases are
/// already written against, so a future second backend would still add one
/// class of this shape rather than reopening every call site.</para>
/// </summary>
internal abstract class GameWindowGraphics : IDisposable
{
/// <summary>Which backend this handle owns.</summary>
public abstract RenderBackendKind Backend { get; }
/// <summary>
/// The live GL context, or null on any other backend. Phases whose owners
/// are still raw GL branch on this; each such branch is a slice of Campaign
/// V that has not landed yet, and the null arm names which one.
/// </summary>
public virtual GL? Gl => null;
/// <summary>The live Vulkan context, or null on any other backend.</summary>
/// <summary>The live Vulkan context.</summary>
public virtual VulkanGraphicsContext? Vulkan => null;
/// <summary>
/// Campaign V slice V6j: the backend's world-pass seam, or null where the
/// world renderers open their own passes.
///
/// <para>It lives here because the three composition phases that need it —
/// world render, live presentation and the frame root — already borrow this
/// handle, and because whether a backend HAS such a seam is exactly the kind
/// of thing this type exists to answer. GL returns null: its renderers
/// submit raw, and the frame spine still owns framebuffer management until
/// V4h.</para>
/// The backend's world-pass seam. Every remaining renderer records into the
/// pass this publishes rather than opening its own.
/// </summary>
public virtual AcDream.App.Rendering.IWorldPassScope? WorldPassScope => null;
/// <summary>
/// The GL context, or a failure naming the caller. Used where the call site
/// has already established that the GL arm is running, so a null would be a
/// composition bug rather than a backend difference.
/// </summary>
public GL RequireGl(string owner) =>
Gl ?? throw new InvalidOperationException(
$"'{owner}' requires the OpenGL context, but the {Backend} backend is active.");
public abstract void Dispose();
}
/// <summary>OpenGL ownership: the Silk.NET <see cref="GL"/> context itself.</summary>
internal sealed class OpenGlGameWindowGraphics : GameWindowGraphics
{
public OpenGlGameWindowGraphics(GL gl) =>
Context = gl ?? throw new ArgumentNullException(nameof(gl));
/// <summary>The owned context. <see cref="GameWindowGraphics.Gl"/> is the borrowed view.</summary>
public GL Context { get; }
public override RenderBackendKind Backend => RenderBackendKind.Gl;
public override GL? Gl => Context;
public override void Dispose() => Context.Dispose();
}
/// <summary>
/// Vulkan ownership: the instance, surface, device, swapchain and RHI device
/// that <see cref="VulkanGraphicsContext"/> acquired and gated.
@ -91,8 +44,6 @@ internal sealed class VulkanGameWindowGraphics : GameWindowGraphics
public VulkanGraphicsContext Context { get; }
public override RenderBackendKind Backend => RenderBackendKind.Vulkan;
public override VulkanGraphicsContext? Vulkan => Context;
/// <summary>The concrete scope, for the phase that publishes the encoder on it.</summary>

View file

@ -4,7 +4,6 @@ using AcDream.App.Rendering.Gpu;
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
using Silk.NET.Maths;
using Silk.NET.OpenGL;
namespace AcDream.App.Composition;
@ -115,91 +114,6 @@ internal interface IHostInputCameraCompositionFactory
PointerPositionState pointer);
}
internal sealed class RetailHostInputCameraCompositionFactory
: IHostInputCameraCompositionFactory
{
public IFramebufferViewportTarget CreateViewportTarget(GameWindowGraphics graphics) =>
new SilkFramebufferViewportTarget(graphics.RequireGl("viewport target"));
public GpuFrameFlightController? CreateGpuFrameFlights(GameWindowGraphics graphics) =>
new(graphics.RequireGl("GPU frame flights"));
public IGpuDevice CreateGpuDevice(
GameWindowGraphics graphics,
GpuFrameFlightController? frameFlights) =>
new AcDream.App.Rendering.Gpu.Gl.GlGpuDevice(
graphics.RequireGl("GPU device (RHI)"),
frameFlights ?? throw new ArgumentNullException(nameof(frameFlights)),
Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders"));
public IGpuResourceRetirementQueue CreateRetirement(
GameWindowGraphics graphics,
GpuFrameFlightController? frameFlights,
IGpuDevice device) =>
frameFlights ?? throw new ArgumentNullException(nameof(frameFlights));
public IRenderFrameSlotSource CreateFrameSlots(
GameWindowGraphics graphics,
GpuFrameFlightController? frameFlights,
IGpuDevice device) =>
frameFlights ?? throw new ArgumentNullException(nameof(frameFlights));
public WorldRenderDiagnostics? CreateWorldRenderDiagnostics(
GameWindowGraphics graphics,
IRenderFrameDiagnosticLog log) =>
new(
new SilkRenderGlStateReader(
graphics.RequireGl("world render diagnostics")),
log);
public SilkKeyboardSource CreateKeyboardSource(
IKeyboard keyboard,
HostQuiescenceGate quiescence) =>
SilkKeyboardSource.CreateDetached(keyboard, quiescence);
public SilkMouseSource CreateMouseSource(
IMouse mouse,
IInputCaptureSource capture,
IKeyboardSource? keyboard,
HostQuiescenceGate quiescence) =>
SilkMouseSource.CreateDetached(mouse, capture, keyboard, quiescence);
public IMouseLookCursor CreateMouseLookCursor(IMouse mouse) =>
new SilkMouseLookCursor(mouse);
public InputDispatcher CreateInputDispatcher(
IKeyboardSource keyboard,
IMouseSource mouse,
KeyBindings bindings) =>
InputDispatcher.CreateDetached(keyboard, mouse, bindings);
public CameraController CreateCameraController() =>
new(new OrbitCamera(), new FlyCamera());
public IFramebufferCameraTarget CreateCameraTarget(CameraController camera) =>
new CameraFramebufferTarget(camera);
public CameraPointerInputController CreateCameraPointerInput(
IReadOnlyList<IMouse> mice,
HostQuiescenceGate quiescence,
IInputCaptureSource capture,
LocalPlayerModeState playerMode,
CameraController camera,
ChaseCameraInputState chase,
IMouseSource mouse,
PointerPositionState pointer) =>
CameraPointerInputController.Create(
mice,
quiescence,
capture,
playerMode,
camera,
chase,
mouse,
pointer,
new EnvironmentInputMonotonicClock());
}
internal enum HostInputCameraCompositionPoint
{
ViewportBound,
@ -236,7 +150,7 @@ internal sealed class HostInputCameraCompositionPhase :
private readonly IHostInputCameraCompositionFactory? _injectedFactory;
private readonly Action<HostInputCameraCompositionPoint>? _faultInjection;
private IHostInputCameraCompositionFactory _factory =
new RetailHostInputCameraCompositionFactory();
new VulkanHostInputCameraCompositionFactory();
public HostInputCameraCompositionPhase(
HostInputCameraDependencies dependencies,
@ -253,17 +167,13 @@ internal sealed class HostInputCameraCompositionPhase :
}
/// <summary>
/// Campaign V slice V6h: the default factory is chosen from the platform
/// result, not from the host. The backend is a property of the graphics
/// ownership that acquisition published, so the phase reads it rather than
/// making every call site branch — and an injected factory (composition
/// The Vulkan factory is the only one left since the raw-GL arm was
/// deleted at Campaign V slice V11; an injected factory (composition
/// tests) still wins.
/// </summary>
private static IHostInputCameraCompositionFactory DefaultFactoryFor(
GameWindowGraphics graphics) =>
graphics.Backend == RenderBackendKind.Vulkan
? new VulkanHostInputCameraCompositionFactory()
: new RetailHostInputCameraCompositionFactory();
new VulkanHostInputCameraCompositionFactory();
public HostInputCameraResult Compose(
GameWindowPlatformResult<GameWindowGraphics, IInputContext> platform)

View file

@ -25,7 +25,6 @@ using AcDream.UI.Abstractions.Panels.Chat;
using AcDream.UI.Abstractions.Panels.Vitals;
using DatReaderWriter;
using Silk.NET.Input;
using Silk.NET.OpenGL;
using Silk.NET.Windowing;
namespace AcDream.App.Composition;

View file

@ -30,7 +30,6 @@ using AcDream.Runtime.Entities;
using AcDream.Runtime.World;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using Silk.NET.OpenGL;
using Silk.NET.Windowing;
namespace AcDream.App.Composition;
@ -678,46 +677,28 @@ internal sealed class LivePresentationCompositionPhase
AlphaScratchBudgetProfile.Create(
d.Options.ResidencyBudgets.AlphaScratchBytes);
// Campaign V slice V6h: every renderer below this line is still raw GL.
// On a backend without a GL context none of them is constructed, and the
// CPU owners this phase also produces — entity lifetime, motion,
// selection, effects, lights, the landblock pipeline — run unchanged.
GL? gl = d.Graphics.Gl;
var selectionScene = new RetailSelectionScene(
new RetailSelectionGeometryCache(content.Dats, d.DatLock));
// Campaign V slice V6j: the world dispatcher exists on BOTH arms. The GL
// arm is unchanged; the RHI arm records into the pass the world scene
// phase publishes on this scope.
// Campaign V slice V6j: the world dispatcher records into the pass the
// world scene phase publishes on this scope. The raw-GL arm was
// deleted at slice V11.
IWorldPassScope? worldPassScope = d.Graphics.WorldPassScope;
var dispatcherLease = scope.Acquire(
"WB draw dispatcher",
() => gl is not null
? new WbDrawDispatcher(
gl,
foundation.MeshShader!,
foundation.TextureCache,
foundation.MeshAdapter!,
entitySpawnAdapter,
foundation.Bindless!,
d.ClassificationCache,
d.TranslucencyFades,
selectionScene,
d.RetailAlphaQueue,
alphaScratchBudgets.DispatcherBytes)
: new WbDrawDispatcher(
host.GpuDevice,
host.GpuFrameLifetime,
worldPassScope
?? throw new InvalidOperationException(
"A backend without a GL context must publish a world pass scope."),
foundation.TextureCache,
foundation.MeshAdapter!,
entitySpawnAdapter,
d.ClassificationCache,
d.TranslucencyFades,
selectionScene,
d.RetailAlphaQueue,
alphaScratchBudgets.DispatcherBytes),
() => new WbDrawDispatcher(
host.GpuDevice,
host.GpuFrameLifetime,
worldPassScope
?? throw new InvalidOperationException(
"The graphics backend must publish a world pass scope."),
foundation.TextureCache,
foundation.MeshAdapter!,
entitySpawnAdapter,
d.ClassificationCache,
d.TranslucencyFades,
selectionScene,
d.RetailAlphaQueue,
alphaScratchBudgets.DispatcherBytes),
static value => value.Dispose());
var selectionQuery = new WorldSelectionQuery(
liveEntities,
@ -815,8 +796,9 @@ internal sealed class LivePresentationCompositionPhase
paperdollLease = scope.Acquire(
"paperdoll viewport",
() => new PaperdollViewportRenderer(
gl,
worldPassScope,
worldPassScope
?? throw new InvalidOperationException(
"The graphics backend must publish a world pass scope."),
host.GpuDevice,
host.GpuFrameLifetime,
paperdollDispatcher,
@ -861,8 +843,9 @@ internal sealed class LivePresentationCompositionPhase
creatureAppraisalLease = scope.Acquire(
"creature appraisal viewport",
() => new CreatureAppraisalViewportRenderer(
gl,
worldPassScope,
worldPassScope
?? throw new InvalidOperationException(
"The graphics backend must publish a world pass scope."),
host.GpuDevice,
host.GpuFrameLifetime,
appraisalDispatcher,
@ -897,24 +880,17 @@ internal sealed class LivePresentationCompositionPhase
var envCellFrustum = new WbFrustum();
var envCellLease = scope.Acquire(
"environment-cell renderer",
() => gl is not null
? new EnvCellRenderer(
gl,
foundation.MeshAdapter!.MeshManager!,
envCellFrustum)
: new EnvCellRenderer(
host.GpuDevice,
host.GpuFrameLifetime,
worldPassScope
?? throw new InvalidOperationException(
"A backend without a GL context must publish a world pass scope."),
foundation.MeshAdapter!.MeshManager!,
envCellFrustum),
() => new EnvCellRenderer(
host.GpuDevice,
host.GpuFrameLifetime,
worldPassScope
?? throw new InvalidOperationException(
"The graphics backend must publish a world pass scope."),
foundation.MeshAdapter!.MeshManager!,
envCellFrustum),
static value => value.Dispose());
// The RHI arm's three pipelines ARE its program, built at construction,
// so only the GL arm has a second initialisation step.
if (foundation.MeshShader is { } envCellShader)
envCellLease.Resource.Initialize(envCellShader);
// The three pipelines ARE its program, built at construction — the
// raw-GL arm's separate Initialize(Shader) step was deleted at V11.
Fault(LivePresentationCompositionPoint.EnvironmentCellsCreated);
// The streaming pipeline itself is backend-neutral and runs on both
@ -972,20 +948,18 @@ internal sealed class LivePresentationCompositionPhase
"portal clip frame",
ClipFrame.NoClip,
static value => value.Dispose());
// Campaign V slice V6l: the portal depth mask exists on BOTH arms. The
// GL arm keeps its inline program and raw draws; the RHI arm compiles
// portal_depth from SPIR-V into three pipelines and records into the
// pass the world scene phase publishes on this scope.
// Campaign V slice V6l: the portal depth mask compiles portal_depth
// from SPIR-V into three pipelines and records into the pass the
// world scene phase publishes on this scope. The raw-GL inline
// program and raw draws were deleted at slice V11.
var portalDepthLease = scope.Acquire(
"portal depth mask",
() => gl is not null
? new PortalDepthMaskRenderer(gl)
: new PortalDepthMaskRenderer(
host.GpuDevice,
host.GpuFrameLifetime,
worldPassScope
?? throw new InvalidOperationException(
"A backend without a GL context must publish a world pass scope.")),
() => new PortalDepthMaskRenderer(
host.GpuDevice,
host.GpuFrameLifetime,
worldPassScope
?? throw new InvalidOperationException(
"The graphics backend must publish a world pass scope.")),
static value => value.Dispose());
CompositionAcquisitionScope.CompositionAcquisitionLease<
PortalWaitNoticeController>? portalWaitNoticeLease = null;
@ -1003,11 +977,9 @@ internal sealed class LivePresentationCompositionPhase
: null;
CompositionAcquisitionScope.CompositionAcquisitionLease<
PortalTunnelPresentation>? portalTunnelLease = null;
// Campaign V slice V6m: portal space exists on BOTH arms. The GL arm is
// unchanged; the RHI arm opens a backbuffer pass of its own and publishes
// it on the scope for the span of the draw, the way the two offscreen
// viewports do. It was the last raw-GL world-adjacent renderer, so the
// Vulkan arm no longer composes a portal-less teleport presentation.
// Campaign V slice V6m: portal space opens a backbuffer pass of its
// own and publishes it on the scope for the span of the draw, the way
// the two offscreen viewports do. The raw-GL arm was deleted at V11.
if (dispatcherLease.Resource is { } portalDispatcher)
{
PortalTunnelPresentation portalTunnel;
@ -1015,8 +987,9 @@ internal sealed class LivePresentationCompositionPhase
{
portalTunnel = d.PortalTunnelFallback.AcquirePrepared(
() => PortalTunnelPresentation.CreateRequired(
gl,
worldPassScope,
worldPassScope
?? throw new InvalidOperationException(
"The graphics backend must publish a world pass scope."),
host.GpuFrameLifetime,
content.Dats,
content.AnimationLoader,
@ -1051,87 +1024,45 @@ internal sealed class LivePresentationCompositionPhase
}
Fault(LivePresentationCompositionPoint.PortalResourcesCreated);
AcDream.App.Rendering.Shader? skyShader = gl is null
? null
: d.RenderResourceLifetime.AcquireSkyShader(
() => new AcDream.App.Rendering.Shader(
gl,
Path.Combine(foundation.ShadersDirectory, "sky.vert"),
Path.Combine(foundation.ShadersDirectory, "sky.frag"),
// Campaign V slice V6e: sky reads the shared texture table and
// ACDREAM_UBO_SET now, both of which common.glsl declares.
includeCommonPreamble: true));
var skyShaderLease = skyShader is null
? null
: scope.Own(
"sky shader lifetime",
skyShader,
_ => d.RenderResourceLifetime.ReleaseSkyShader());
// Campaign V slice V6k: the sky exists on BOTH arms. The GL arm is
// unchanged except for where its table slots come from; the RHI arm
// compiles the sky pair from SPIR-V and records into the pass the world
// scene phase publishes on this scope.
// Campaign V slice V6k: the sky compiles its pair from SPIR-V and
// records into the pass the world scene phase publishes on this
// scope. The raw-GL arm — its own shader pair (acquired through
// GameRenderResourceLifetime.AcquireSkyShader, deleted with it) and
// its own bindless/device-table wiring — was deleted at slice V11.
var skyLease = scope.Acquire(
"sky renderer",
() => gl is not null
? new SkyRenderer(
gl,
content.Dats,
skyShader!,
foundation.TextureCache,
foundation.Samplers!,
// Slice V6e: the sky samples through the shared texture table,
// so it needs the same bindless entry point the world path uses.
foundation.Bindless!,
// Slice V6k: and V4t's world-handle seam on the device, which
// retired the last per-renderer GlBindlessHandleTable.
(AcDream.App.Rendering.Gpu.Gl.GlGpuDevice)host.GpuDevice)
{
// Campaign V slice V7: null unless ACDREAM_SKY_PHASE_SECONDS
// is set, which is every run but a differential gate's.
AnimationPhaseSecondsOverride = d.Options.SkyAnimationPhaseSeconds,
}
: new SkyRenderer(
host.GpuDevice,
host.GpuFrameLifetime,
worldPassScope
?? throw new InvalidOperationException(
"A backend without a GL context must publish a world pass scope."),
content.Dats,
foundation.TextureCache)
{
AnimationPhaseSecondsOverride = d.Options.SkyAnimationPhaseSeconds,
},
() => new SkyRenderer(
host.GpuDevice,
host.GpuFrameLifetime,
worldPassScope
?? throw new InvalidOperationException(
"The graphics backend must publish a world pass scope."),
content.Dats,
foundation.TextureCache)
{
// Campaign V slice V7: null unless ACDREAM_SKY_PHASE_SECONDS
// is set, which is every run but a differential gate's.
AnimationPhaseSecondsOverride = d.Options.SkyAnimationPhaseSeconds,
},
static value => value.Dispose());
// Campaign V slice V6l: particles exist on BOTH arms. The GL arm is
// unchanged; the RHI arm compiles the two particle pairs from SPIR-V,
// draws instances through the vertex binding the V6l contract amendment
// added, and records into the pass the world scene phase publishes on
// this scope.
// Campaign V slice V6l: the RHI arm compiles the two particle pairs
// from SPIR-V, draws instances through the vertex binding the V6l
// contract amendment added, and records into the pass the world scene
// phase publishes on this scope. The raw-GL arm was deleted at V11.
var particleLease = scope.AcquireOptional(
"particle renderer",
() => gl is not null
? new ParticleRenderer(
gl,
foundation.ShadersDirectory,
content.ParticleSystem,
foundation.TextureCache,
content.Dats,
foundation.MeshAdapter!,
d.RetailAlphaQueue,
alphaScratchBudgets.ParticleBytes)
: new ParticleRenderer(
host.GpuDevice,
host.GpuFrameLifetime,
worldPassScope
?? throw new InvalidOperationException(
"A backend without a GL context must publish a world pass scope."),
content.ParticleSystem,
foundation.TextureCache,
content.Dats,
foundation.MeshAdapter!,
d.RetailAlphaQueue,
alphaScratchBudgets.ParticleBytes),
() => new ParticleRenderer(
host.GpuDevice,
host.GpuFrameLifetime,
worldPassScope
?? throw new InvalidOperationException(
"The graphics backend must publish a world pass scope."),
content.ParticleSystem,
foundation.TextureCache,
content.Dats,
foundation.MeshAdapter!,
d.RetailAlphaQueue,
alphaScratchBudgets.ParticleBytes),
static value => value.Dispose());
Fault(LivePresentationCompositionPoint.SkyAndParticlesCreated);
@ -1252,7 +1183,6 @@ internal sealed class LivePresentationCompositionPhase
portalTunnelLease?.Transfer();
portalWaitNoticeLease?.Transfer();
skyLease.Transfer();
skyShaderLease?.Transfer();
particleLease.Transfer();
bindingsLease.Transfer();
Fault(LivePresentationCompositionPoint.ResultPublished);

View file

@ -8,28 +8,22 @@ using Silk.NET.Input;
namespace AcDream.App.Composition;
/// <summary>
/// Campaign V slice V6h: the Vulkan arm of the host phase.
///
/// <para>This is the whole of the backend fork at composition Phase 1. Only the
/// four graphics members differ from
/// <see cref="RetailHostInputCameraCompositionFactory"/>; input, camera and
/// pointer construction are platform concerns, not graphics ones, so they
/// delegate rather than duplicate. Adding a fifth backend would add one more
/// class of this shape and touch nothing else in the composition pipeline —
/// which is the property §5.5.9 asked this slice to establish.</para>
/// Campaign V slice V6h: the Vulkan arm of the host phase — now the only arm,
/// the raw-GL <c>RetailHostInputCameraCompositionFactory</c> it used to fork
/// from having been deleted at slice V11. Input, camera and pointer
/// construction are inlined directly rather than delegated, since there is no
/// longer a second implementation to share them with.
///
/// <para><b>What is absent, and why.</b> There is no GL fence ring: the RHI
/// device owns its own frames-in-flight through a timeline semaphore, so
/// retirement and slot indexing come from the device instead. There is no
/// <see cref="WorldRenderDiagnostics"/>: it is a raw-GL state tripwire, and
/// <see cref="WorldRenderDiagnostics"/>: it was a raw-GL state tripwire, and
/// Vulkan has no global state for it to watch. Both nulls are read by the
/// phases that would otherwise consume them.</para>
/// </summary>
internal sealed class VulkanHostInputCameraCompositionFactory
: IHostInputCameraCompositionFactory
{
private readonly RetailHostInputCameraCompositionFactory _platform = new();
public IFramebufferViewportTarget CreateViewportTarget(
GameWindowGraphics graphics) =>
// The viewport is a pipeline dynamic state on Vulkan, set per pass by
@ -64,29 +58,29 @@ internal sealed class VulkanHostInputCameraCompositionFactory
public SilkKeyboardSource CreateKeyboardSource(
IKeyboard keyboard,
HostQuiescenceGate quiescence) =>
_platform.CreateKeyboardSource(keyboard, quiescence);
SilkKeyboardSource.CreateDetached(keyboard, quiescence);
public SilkMouseSource CreateMouseSource(
IMouse mouse,
IInputCaptureSource capture,
IKeyboardSource? keyboard,
HostQuiescenceGate quiescence) =>
_platform.CreateMouseSource(mouse, capture, keyboard, quiescence);
SilkMouseSource.CreateDetached(mouse, capture, keyboard, quiescence);
public IMouseLookCursor CreateMouseLookCursor(IMouse mouse) =>
_platform.CreateMouseLookCursor(mouse);
new SilkMouseLookCursor(mouse);
public InputDispatcher CreateInputDispatcher(
IKeyboardSource keyboard,
IMouseSource mouse,
KeyBindings bindings) =>
_platform.CreateInputDispatcher(keyboard, mouse, bindings);
InputDispatcher.CreateDetached(keyboard, mouse, bindings);
public CameraController CreateCameraController() =>
_platform.CreateCameraController();
new(new OrbitCamera(), new FlyCamera());
public IFramebufferCameraTarget CreateCameraTarget(CameraController camera) =>
_platform.CreateCameraTarget(camera);
new CameraFramebufferTarget(camera);
public CameraPointerInputController CreateCameraPointerInput(
IReadOnlyList<IMouse> mice,
@ -97,7 +91,7 @@ internal sealed class VulkanHostInputCameraCompositionFactory
ChaseCameraInputState chase,
IMouseSource mouse,
PointerPositionState pointer) =>
_platform.CreateCameraPointerInput(
CameraPointerInputController.Create(
mice,
quiescence,
capture,
@ -105,14 +99,15 @@ internal sealed class VulkanHostInputCameraCompositionFactory
camera,
chase,
mouse,
pointer);
pointer,
new EnvironmentInputMonotonicClock());
private static VulkanGraphicsContext RequireContext(
GameWindowGraphics graphics) =>
graphics.Vulkan
?? throw new InvalidOperationException(
"The Vulkan host factory was composed against the " +
$"{graphics.Backend} backend.");
"The Vulkan host factory was composed against a backend with no " +
"Vulkan context.");
/// <summary>
/// Vulkan sets the viewport per pass from the pass extent, so there is no

View file

@ -14,7 +14,6 @@ using DatReaderWriter.DBObjs;
using Microsoft.Extensions.Logging.Abstractions;
using Silk.NET.Input;
using Silk.NET.OpenGL;
using Shader = AcDream.App.Rendering.Shader;
namespace AcDream.App.Composition;
@ -31,27 +30,21 @@ internal sealed record WorldTerrainBuildContext(
/// <summary>
/// The render foundation the later phases build on.
///
/// <para>Campaign V slice V6h made every raw-GL member nullable. They are all
/// present on GL and all absent on Vulkan, because the world renderers that own
/// them are still raw GL until slices V4t/V4c/V4d land the Vulkan world arm.
/// What survives on both backends is exactly the RHI-ported set — the texture
/// cache's UI path, the debug font, the text renderer and the debug lines — plus
/// the backend-neutral residency ledger and shader directory.</para>
/// <para>Every member here is backend-neutral. The raw-GL-only members this
/// record used to carry (<c>Bindless</c>, <c>TerrainShader</c>, <c>MeshShader</c>,
/// <c>Samplers</c>) were deleted at Campaign V slice V11 along with the GL arm
/// that was their only producer.</para>
/// </summary>
internal sealed record WorldRenderFoundation(
string ShadersDirectory,
BindlessSupport? Bindless,
TerrainAtlas? TerrainAtlas,
Shader? TerrainShader,
SceneLightingUboBinding? SceneLighting,
DebugLineRenderer DebugLines,
BitmapFont? DebugFont,
TextRenderer? TextRenderer,
TerrainModernRenderer? Terrain,
Shader? MeshShader,
WbMeshAdapter? MeshAdapter,
TextureCache TextureCache,
SamplerCache? Samplers,
ResidencyManager Residency);
internal sealed record WorldRenderResult(
@ -71,8 +64,6 @@ internal sealed record WorldRenderDependencies(
internal interface IGameWindowWorldRenderPublication
{
void PublishBindlessSupport(BindlessSupport value);
void PublishTerrainShader(Shader value);
void PublishSceneLighting(SceneLightingUboBinding value);
void PublishDebugLines(DebugLineRenderer value);
void PublishHudResources(BitmapFont font, TextRenderer text);
@ -81,27 +72,20 @@ internal interface IGameWindowWorldRenderPublication
float[] heightTable,
TerrainBlendingContext blending,
ConcurrentDictionary<uint, SurfaceInfo> surfaceCache);
void PublishMeshShader(Shader value);
void PublishWbMeshAdapter(WbMeshAdapter value);
void PublishTextureCache(TextureCache value);
void PublishSamplerCache(SamplerCache value);
}
internal interface IWorldRenderCompositionFactory
{
void InitializeGlState(GL gl);
WorldRegionData LoadRegion(IDatReaderWriter dats);
void InitializeEnvironment(WorldEnvironmentController environment, Region region);
BindlessSupport RequireBindless(GL gl, Action<string> log);
TerrainAtlas AcquireTerrainAtlas(
IGameRenderResourceLifetime lifetime,
GL gl,
IDatReaderWriter dats,
BindlessSupport bindless);
/// <summary>
/// Campaign V slice V6i-2: the terrain atlas built through
/// <see cref="AcDream.App.Rendering.Gpu.IGpuDevice"/> rather than raw GL.
/// Same DATs, same decode, same layer ordering — only the upload differs.
/// <see cref="AcDream.App.Rendering.Gpu.IGpuDevice"/>. The raw-GL arm this
/// used to fork from (<c>AcquireTerrainAtlas</c>) was deleted at slice V11;
/// the name keeps its "BackendNeutral" suffix rather than being renamed, to
/// avoid touching every call site for a purely cosmetic change.
/// </summary>
TerrainAtlas AcquireBackendNeutralTerrainAtlas(
IGameRenderResourceLifetime lifetime,
@ -120,12 +104,11 @@ internal interface IWorldRenderCompositionFactory
Action<string> log);
void SetTerrainAnisotropic(TerrainAtlas atlas, int level);
Shader CreateTerrainShader(GL gl, string shadersDirectory);
SceneLightingUboBinding CreateSceneLighting(GL gl);
/// <summary>
/// Campaign V slice V6j: scene lighting on a backend with no global uniform
/// binding point. It publishes a ring section on the world pass scope and
/// each renderer binds it inside the pass.
/// each renderer binds it inside the pass. The raw-GL global-uniform-binding
/// arm (<c>CreateSceneLighting</c>) was deleted at slice V11.
/// </summary>
SceneLightingUboBinding CreateBackendNeutralSceneLighting(
ICurrentGpuFrameSource frameSource,
@ -140,17 +123,11 @@ internal interface IWorldRenderCompositionFactory
AcDream.App.Rendering.Gpu.IGpuDevice device,
ICurrentGpuFrameSource frameSource,
string shadersDirectory);
TerrainModernRenderer CreateTerrain(
GL gl,
BindlessSupport bindless,
Shader shader,
TerrainAtlas atlas,
AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice,
IGpuResourceRetirementQueue retirement);
/// <summary>
/// Campaign V slice V6j: terrain's RHI arm. No GL context, no linked
/// program — the pipeline compiles <c>terrain_modern</c> from the committed
/// SPIR-V and records into the world pass the scope publishes.
/// SPIR-V and records into the world pass the scope publishes. The raw-GL
/// arm (<c>CreateTerrain</c>) was deleted at slice V11.
/// </summary>
TerrainModernRenderer CreateBackendNeutralTerrain(
AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice,
@ -170,7 +147,6 @@ internal interface IWorldRenderCompositionFactory
uint initialCenterLandblockId,
float[] heightTable,
TerrainAtlas? atlas);
Shader CreateMeshShader(GL gl, string shadersDirectory);
WbMeshAdapter CreateMeshAdapter(
GL? gl,
AcDream.App.Rendering.Gpu.IGpuDevice device,
@ -179,10 +155,8 @@ internal interface IWorldRenderCompositionFactory
IGpuResourceRetirementQueue retirement,
ResidencyBudgetOptions budgets);
TextureCache CreateTextureCache(
GL? gl,
AcDream.App.Rendering.Gpu.IGpuDevice device,
IDatReaderWriter dats,
BindlessSupport? bindless,
IGpuResourceRetirementQueue retirement,
string diagnosticsDirectory,
ResidencyBudgetOptions budgets);
@ -193,20 +167,12 @@ internal interface IWorldRenderCompositionFactory
IPreparedAssetSource preparedAssets,
IAnimationLoader animations,
DatSoundCache? audio);
SamplerCache CreateSamplerCache(GL gl);
void Release(IDisposable resource);
}
internal sealed class RetailWorldRenderCompositionFactory
: IWorldRenderCompositionFactory
{
public void InitializeGlState(GL gl)
{
ArgumentNullException.ThrowIfNull(gl);
gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f);
gl.Enable(EnableCap.DepthTest);
}
public WorldRegionData LoadRegion(IDatReaderWriter dats)
{
ArgumentNullException.ThrowIfNull(dats);
@ -231,46 +197,6 @@ internal sealed class RetailWorldRenderCompositionFactory
environment.Initialize(region);
}
public BindlessSupport RequireBindless(GL gl, Action<string> log)
{
ArgumentNullException.ThrowIfNull(gl);
ArgumentNullException.ThrowIfNull(log);
if (BindlessSupport.TryCreate(gl, out BindlessSupport? bindless))
{
if (bindless!.HasShaderDrawParameters(gl))
{
log("[N.5] modern path capabilities present " +
"(bindless + ARB_shader_draw_parameters)");
return bindless;
}
log("[N.5] GL_ARB_shader_draw_parameters not present — " +
"modern path not available");
}
else
{
log("[N.5] GL_ARB_bindless_texture not present — " +
"modern path not available");
}
throw new NotSupportedException(
"acdream requires GL_ARB_bindless_texture + " +
"GL_ARB_shader_draw_parameters (GL 4.3+ with bindless support). " +
"Your GPU/driver does not expose these extensions. If this is " +
"unexpected, please file a bug report with your GPU vendor + " +
"driver version.");
}
public TerrainAtlas AcquireTerrainAtlas(
IGameRenderResourceLifetime lifetime,
GL gl,
IDatReaderWriter dats,
BindlessSupport bindless)
{
ArgumentNullException.ThrowIfNull(lifetime);
return lifetime.AcquireTerrainAtlas(
() => TerrainAtlas.Build(gl, dats, bindless));
}
public TerrainAtlas AcquireBackendNeutralTerrainAtlas(
IGameRenderResourceLifetime lifetime,
AcDream.App.Rendering.Gpu.IGpuDevice device,
@ -289,15 +215,6 @@ internal sealed class RetailWorldRenderCompositionFactory
public void SetTerrainAnisotropic(TerrainAtlas atlas, int level) =>
atlas.SetAnisotropic(level);
public Shader CreateTerrainShader(GL gl, string shadersDirectory) =>
new(
gl,
Path.Combine(shadersDirectory, "terrain_modern.vert"),
Path.Combine(shadersDirectory, "terrain_modern.frag"),
includeCommonPreamble: true);
public SceneLightingUboBinding CreateSceneLighting(GL gl) => new(gl);
public SceneLightingUboBinding CreateBackendNeutralSceneLighting(
ICurrentGpuFrameSource frameSource,
IWorldPassScope scope) =>
@ -321,25 +238,6 @@ internal sealed class RetailWorldRenderCompositionFactory
string shadersDirectory) =>
new(device, frameSource, shadersDirectory);
public TerrainModernRenderer CreateTerrain(
GL gl,
BindlessSupport bindless,
Shader shader,
TerrainAtlas atlas,
AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice,
IGpuResourceRetirementQueue retirement) =>
// Campaign V slice V4t: terrain's atlas slots live in the device's one
// texture table. This renderer only exists on GL — the `gl is not null`
// gate at its call site is the same one — so the backend cast is a
// statement of that fact rather than a narrowing.
new(
gl,
bindless,
shader,
atlas,
(AcDream.App.Rendering.Gpu.Gl.GlGpuDevice)gpuDevice,
retirement);
public TerrainModernRenderer CreateBackendNeutralTerrain(
AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice,
ICurrentGpuFrameSource frameSource,
@ -400,13 +298,6 @@ internal sealed class RetailWorldRenderCompositionFactory
new ConcurrentDictionary<uint, SurfaceInfo>());
}
public Shader CreateMeshShader(GL gl, string shadersDirectory) =>
new(
gl,
Path.Combine(shadersDirectory, "mesh_modern.vert"),
Path.Combine(shadersDirectory, "mesh_modern.frag"),
includeCommonPreamble: true);
public WbMeshAdapter CreateMeshAdapter(
GL? gl,
AcDream.App.Rendering.Gpu.IGpuDevice device,
@ -424,18 +315,14 @@ internal sealed class RetailWorldRenderCompositionFactory
budgets);
public TextureCache CreateTextureCache(
GL? gl,
AcDream.App.Rendering.Gpu.IGpuDevice device,
IDatReaderWriter dats,
BindlessSupport? bindless,
IGpuResourceRetirementQueue retirement,
string diagnosticsDirectory,
ResidencyBudgetOptions budgets) =>
new(
gl,
device,
dats,
bindless,
retirement,
diagnosticsDirectory,
budgets);
@ -501,19 +388,14 @@ internal sealed class RetailWorldRenderCompositionFactory
}
}
public SamplerCache CreateSamplerCache(GL gl) => new(gl);
public void Release(IDisposable resource) => resource.Dispose();
}
internal enum WorldRenderCompositionPoint
{
GlStateInitialized,
RegionLoaded,
EnvironmentInitialized,
BindlessPublished,
TerrainAtlasAcquired,
TerrainShaderPublished,
SceneLightingPublished,
DebugLinesPublished,
DebugFontCreated,
@ -522,10 +404,8 @@ internal enum WorldRenderCompositionPoint
HudResourcesCompleted,
TerrainPublished,
TerrainBuildStatePublished,
MeshShaderPublished,
MeshAdapterPublished,
TextureCachePublished,
SamplerCachePublished,
}
/// <summary>
@ -571,91 +451,53 @@ internal sealed class WorldRenderCompositionPhase
var scope = new CompositionAcquisitionScope();
try
{
// Campaign V slice V6h: null on a backend with no GL context. Every
// world renderer below is still raw GL, so the Vulkan arm composes
// the RHI-ported subset only — the texture cache's UI path, the
// debug font, the text renderer, the debug lines — and leaves the
// world absent until slices V4t/V4c/V4d land it.
GL? gl = platform.Graphics.Gl;
var residency = new ResidencyManager(
_dependencies.ResidencyBudgets);
if (gl is not null)
_factory.InitializeGlState(gl);
Fault(WorldRenderCompositionPoint.GlStateInitialized);
WorldRegionData region = _factory.LoadRegion(content.Dats);
Fault(WorldRenderCompositionPoint.RegionLoaded);
_factory.InitializeEnvironment(_dependencies.Environment, region.Region);
Fault(WorldRenderCompositionPoint.EnvironmentInitialized);
BindlessSupport? bindless = gl is null
? null
: _factory.RequireBindless(gl, _dependencies.Log);
if (bindless is not null)
_publication.PublishBindlessSupport(bindless);
Fault(WorldRenderCompositionPoint.BindlessPublished);
// Campaign V slice V6i-2: the atlas exists on BOTH arms now. GL
// builds it from raw texture names as before; a backend with no GL
// context builds the same layers through IGpuDevice.CreateTexture.
// That is what makes the blending/T-code tables below real on
// Vulkan — and, more to the point, it is what puts the new creation
// path under the validation layer instead of leaving it a claim.
TerrainAtlas? terrainAtlas = gl is not null && bindless is not null
? _factory.AcquireTerrainAtlas(
_dependencies.RenderResources,
gl,
content.Dats,
bindless)
: _factory.AcquireBackendNeutralTerrainAtlas(
_dependencies.RenderResources,
_dependencies.GpuDevice,
content.Dats);
// Campaign V slice V6i-2: the atlas builds through IGpuDevice on
// every remaining backend. The raw-GL arm that built it from raw
// texture names was deleted at slice V11.
TerrainAtlas terrainAtlas = _factory.AcquireBackendNeutralTerrainAtlas(
_dependencies.RenderResources,
_dependencies.GpuDevice,
content.Dats);
_factory.SetTerrainAnisotropic(
terrainAtlas,
settings.ResolvedQuality.AnisotropicLevel);
Fault(WorldRenderCompositionPoint.TerrainAtlasAcquired);
// The rest of the world texture stack's creation path, exercised on
// the arm that has no GL: one shared array of each format family and
// one composite array, created and released here. Nothing draws
// them; see the type's own documentation for why they are built
// anyway. It claims no composition point and enters no acquisition
// scope — the frozen publication order is a pinned assertion, and an
// exercise that owns nothing past its own statement is not a
// published owner.
if (gl is null)
{
_factory.ExerciseBackendNeutralWorldTextures(
_dependencies.GpuDevice,
_dependencies.Log);
}
// The rest of the world texture stack's creation path: one shared
// array of each format family and one composite array, created and
// released here. Nothing draws them; see the type's own
// documentation for why they are built anyway. It claims no
// composition point and enters no acquisition scope — the frozen
// publication order is a pinned assertion, and an exercise that
// owns nothing past its own statement is not a published owner.
_factory.ExerciseBackendNeutralWorldTextures(
_dependencies.GpuDevice,
_dependencies.Log);
string shadersDirectory = Path.Combine(
AppContext.BaseDirectory,
"Rendering",
"Shaders");
Shader? terrainShader = AcquireAndPublishIf(
gl is not null,
scope,
"terrain shader",
() => _factory.CreateTerrainShader(gl!, shadersDirectory),
_publication.PublishTerrainShader,
WorldRenderCompositionPoint.TerrainShaderPublished);
// Campaign V slice V6j: scene lighting exists on BOTH arms — the
// world renderers read it on both, and on the RHI arm it publishes a
// ring section instead of holding a global binding point.
IWorldPassScope? worldPassScope = platform.Graphics.WorldPassScope;
SceneLightingUboBinding? sceneLighting = AcquireAndPublish(
// Campaign V slice V6j: scene lighting publishes a ring section on
// the world pass scope; the raw-GL global-uniform-binding arm was
// deleted at slice V11.
IWorldPassScope worldPassScope = platform.Graphics.WorldPassScope
?? throw new InvalidOperationException(
"The graphics backend must publish a world pass scope.");
SceneLightingUboBinding sceneLighting = AcquireAndPublish(
scope,
"scene lighting",
() => gl is not null
? _factory.CreateSceneLighting(gl)
: _factory.CreateBackendNeutralSceneLighting(
_dependencies.GpuFrameSource,
worldPassScope
?? throw new InvalidOperationException(
"A backend without a GL context must publish a world pass scope.")),
() => _factory.CreateBackendNeutralSceneLighting(
_dependencies.GpuFrameSource,
worldPassScope),
_publication.PublishSceneLighting,
WorldRenderCompositionPoint.SceneLightingPublished);
DebugLineRenderer debugLines = AcquireAndPublish(
@ -671,28 +513,18 @@ internal sealed class WorldRenderCompositionPhase
(BitmapFont? debugFont, TextRenderer? textRenderer) =
ComposeOptionalHudResources(scope, shadersDirectory);
// Campaign V slice V6j: terrain exists on BOTH arms. The GL arm is
// unchanged; the RHI arm records into the world pass and reads the
// same backend-neutral atlas built above.
TerrainModernRenderer? terrain = AcquireAndPublish(
// Campaign V slice V6j: terrain records into the world pass and
// reads the same backend-neutral atlas built above. The raw-GL arm
// was deleted at slice V11.
TerrainModernRenderer terrain = AcquireAndPublish(
scope,
"terrain renderer",
() => gl is not null
? _factory.CreateTerrain(
gl,
bindless!,
terrainShader!,
terrainAtlas!,
_dependencies.GpuDevice,
_dependencies.ResourceRetirement)
: _factory.CreateBackendNeutralTerrain(
_dependencies.GpuDevice,
_dependencies.GpuFrameSource,
worldPassScope
?? throw new InvalidOperationException(
"A backend without a GL context must publish a world pass scope."),
terrainAtlas!,
_dependencies.ResourceRetirement),
() => _factory.CreateBackendNeutralTerrain(
_dependencies.GpuDevice,
_dependencies.GpuFrameSource,
worldPassScope,
terrainAtlas,
_dependencies.ResourceRetirement),
_publication.PublishTerrain,
WorldRenderCompositionPoint.TerrainPublished);
@ -707,25 +539,17 @@ internal sealed class WorldRenderCompositionPhase
terrainBuild.SurfaceCache);
Fault(WorldRenderCompositionPoint.TerrainBuildStatePublished);
Shader? meshShader = AcquireAndPublishIf(
gl is not null,
scope,
"mesh shader",
() => _factory.CreateMeshShader(gl!, shadersDirectory),
_publication.PublishMeshShader,
WorldRenderCompositionPoint.MeshShaderPublished);
if (meshShader is not null)
_dependencies.Log("[N.5] mesh_modern shader loaded");
// Campaign V slice V6i-3: the mesh pipeline exists on BOTH arms. Its
// upload bodies reached IGpuBuffer, so a backend with no GL context
// builds the same arena, the same atlases and the same render data —
// which is what makes streaming's publication into GPU state real
// there rather than a no-op.
// Campaign V slice V6i-3: the mesh pipeline builds the same arena,
// atlases and render data on every remaining backend, which is what
// makes streaming's publication into GPU state real rather than a
// no-op. gl is always null here — the raw-GL arm was deleted at
// slice V11, and the constructor's GL? gl parameter is Commit 3's
// (IMeshPipelineDevice.Gl) to remove.
WbMeshAdapter meshAdapter = AcquireAndPublish(
scope,
"WB mesh adapter",
() => _factory.CreateMeshAdapter(
gl,
gl: null,
_dependencies.GpuDevice,
content.Dats,
content.PreparedAssets,
@ -737,22 +561,13 @@ internal sealed class WorldRenderCompositionPhase
scope,
"texture cache",
() => _factory.CreateTextureCache(
gl,
_dependencies.GpuDevice,
content.Dats,
bindless,
_dependencies.ResourceRetirement,
_dependencies.DiagnosticsDirectory,
residency.Budgets),
_publication.PublishTextureCache,
WorldRenderCompositionPoint.TextureCachePublished);
SamplerCache? samplers = AcquireAndPublishIf(
gl is not null,
scope,
"sampler cache",
() => _factory.CreateSamplerCache(gl!),
_publication.PublishSamplerCache,
WorldRenderCompositionPoint.SamplerCachePublished);
_factory.RegisterResidencySources(
residency,
meshAdapter,
@ -769,18 +584,14 @@ internal sealed class WorldRenderCompositionPhase
terrainBuild,
new WorldRenderFoundation(
shadersDirectory,
bindless,
terrainAtlas,
terrainShader,
sceneLighting,
debugLines,
debugFont,
textRenderer,
terrain,
meshShader,
meshAdapter,
textureCache,
samplers,
residency));
}
catch (Exception failure)
@ -843,27 +654,6 @@ internal sealed class WorldRenderCompositionPhase
return value;
}
/// <summary>
/// Campaign V slice V6h: acquires an owner the active backend may not have.
/// The fault point still fires on both arms so a failure-injection test
/// covers the same ordered sequence whichever backend composed it.
/// </summary>
private T? AcquireAndPublishIf<T>(
bool supported,
CompositionAcquisitionScope scope,
string name,
Func<T> factory,
Action<T> publish,
WorldRenderCompositionPoint point)
where T : class, IDisposable
{
T? value = supported
? scope.Acquire(name, factory, _factory.Release).Publish(publish)
: null;
Fault(point);
return value;
}
private void Fault(WorldRenderCompositionPoint point) =>
_faultInjection?.Invoke(point);
}

View file

@ -1,6 +1,4 @@
using AcDream.App.Rendering.Wb;
using Silk.NET.OpenGL;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
namespace AcDream.App.Diagnostics;
@ -28,39 +26,6 @@ internal sealed class FrameScreenshotController
private readonly Dictionary<string, CaptureStatus> _status =
new(StringComparer.OrdinalIgnoreCase);
public FrameScreenshotController(
GL gl,
string directory,
Action<string>? log = null)
: this(
CreateReader(gl),
directory,
log)
{
}
private static Func<int, int, byte[]> CreateReader(GL gl)
{
ArgumentNullException.ThrowIfNull(gl);
var surface = new GlDefaultFramebufferSurface(gl);
return (width, height) => ReadDefaultFramebuffer(surface, width, height);
}
/// <summary>
/// Reads the default framebuffer through the same resolve-aware path the
/// screenshot gates use. Shared so there is exactly one implementation of
/// "read the backbuffer" in the process — see
/// <see cref="ReadDefaultFramebuffer(IDefaultFramebufferSurface, int, int)"/>.
/// </summary>
internal static byte[] ReadDefaultFramebuffer(GL gl, int width, int height)
{
ArgumentNullException.ThrowIfNull(gl);
return ReadDefaultFramebuffer(
new GlDefaultFramebufferSurface(gl),
width,
height);
}
internal FrameScreenshotController(
Func<int, int, byte[]> readRgba,
string directory,
@ -186,7 +151,7 @@ internal sealed class FrameScreenshotController
/// <summary>
/// Blits the whole colour buffer from the bound read framebuffer to the
/// bound draw framebuffer with <c>GL_NEAREST</c> and identical rectangles
/// the multisample resolve.
/// — the multisample resolve.
/// </summary>
void BlitColorNearest(int width, int height);
@ -194,7 +159,7 @@ internal sealed class FrameScreenshotController
}
/// <summary>
/// Reads the default framebuffer — framebuffer name 0, the backbuffer —
/// Reads the default framebuffer — framebuffer name 0, the backbuffer —
/// and nothing else.
/// </summary>
/// <remarks>
@ -204,13 +169,13 @@ internal sealed class FrameScreenshotController
/// offscreen target the previous renderer left bound. The frame this runs in
/// draws several: <c>PrivateEntityViewportRenderer</c> clears its paperdoll
/// and appraisal FBOs to exactly RGBA(0,0,0,0), which is what a leaked
/// binding writes to disk a fully transparent PNG that reads as a
/// binding writes to disk — a fully transparent PNG that reads as a
/// blank-world failure while the backbuffer on screen was correct.
/// </para>
/// <para>
/// This was latent for as long as something else rebound framebuffer 0 often
/// enough to mask it (before Campaign V slice V4c, GL <c>BeginPass</c> did so
/// on every pass — see plan §5.4). The capture states its own source instead
/// on every pass — see plan §5.4). The capture states its own source instead
/// of inheriting one, and restores the caller's binding so a diagnostic
/// capture cannot perturb the frame it observes.
/// </para>
@ -218,15 +183,15 @@ internal sealed class FrameScreenshotController
/// <b>Multisampling.</b> The window is created with the quality preset's
/// MSAA sample count, so the default framebuffer is normally 4x multisampled,
/// and <c>glReadPixels</c> against a multisampled read framebuffer is
/// <i>undefined</i> per the GL spec (GL 4.6 §18.2: an INVALID_OPERATION is
/// <i>undefined</i> per the GL spec (GL 4.6 §18.2: an INVALID_OPERATION is
/// generated only for framebuffer objects; for the default framebuffer the
/// result is simply unspecified, and AMD returns real pixels most of the time
/// and something else the rest). Every automated pixel gate and every blank-
/// world verdict in Campaign V reads through here, so an unspecified read is
/// an unsound instrument, not a cosmetic issue. When the default framebuffer
/// is multisampled the capture resolves it first blit the whole colour
/// is multisampled the capture resolves it first — blit the whole colour
/// buffer into a single-sampled RGBA8 framebuffer with identical rectangles
/// and <c>GL_NEAREST</c>, which is the defined resolve and reads that.
/// and <c>GL_NEAREST</c>, which is the defined resolve — and reads that.
/// A single-sampled default framebuffer keeps the original direct read, so
/// non-MSAA captures stay byte-for-byte what they were.
/// </para>
@ -271,7 +236,7 @@ internal sealed class FrameScreenshotController
uint resolve = surface.CreateResolveTarget(width, height);
try
{
// Read is still framebuffer 0 the multisampled source.
// Read is still framebuffer 0 — the multisampled source.
surface.BindDrawFramebuffer(resolve);
surface.BlitColorNearest(width, height);
surface.BindReadFramebuffer(resolve);
@ -283,134 +248,4 @@ internal sealed class FrameScreenshotController
}
}
private sealed class GlDefaultFramebufferSurface : IDefaultFramebufferSurface
{
private readonly GL _gl;
private uint _resolveRenderbuffer;
public GlDefaultFramebufferSurface(GL gl) => _gl = gl;
public uint ReadFramebufferBinding
{
get
{
_gl.GetInteger(GetPName.ReadFramebufferBinding, out int binding);
return (uint)binding;
}
}
public uint DrawFramebufferBinding
{
get
{
_gl.GetInteger(GetPName.DrawFramebufferBinding, out int binding);
return (uint)binding;
}
}
public int DefaultFramebufferSamples
{
get
{
_gl.GetInteger(GetPName.Samples, out int samples);
return samples;
}
}
public void BindReadFramebuffer(uint framebuffer) =>
_gl.BindFramebuffer(FramebufferTarget.ReadFramebuffer, framebuffer);
public void BindDrawFramebuffer(uint framebuffer) =>
_gl.BindFramebuffer(FramebufferTarget.DrawFramebuffer, framebuffer);
public uint CreateResolveTarget(int width, int height)
{
// RGBA8 matches the default framebuffer's colour encoding — the app
// never enables GL_FRAMEBUFFER_SRGB — so the blit is a pure resolve
// with no encoding conversion.
uint renderbuffer = _gl.GenRenderbuffer();
_gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, renderbuffer);
_gl.RenderbufferStorage(
RenderbufferTarget.Renderbuffer,
InternalFormat.Rgba8,
(uint)width,
(uint)height);
_gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, 0u);
uint framebuffer = _gl.GenFramebuffer();
_gl.BindFramebuffer(FramebufferTarget.DrawFramebuffer, framebuffer);
_gl.FramebufferRenderbuffer(
FramebufferTarget.DrawFramebuffer,
FramebufferAttachment.ColorAttachment0,
RenderbufferTarget.Renderbuffer,
renderbuffer);
GLEnum status =
_gl.CheckFramebufferStatus(FramebufferTarget.DrawFramebuffer);
if (status != GLEnum.FramebufferComplete)
{
_gl.BindFramebuffer(FramebufferTarget.DrawFramebuffer, 0u);
_gl.DeleteFramebuffer(framebuffer);
_gl.DeleteRenderbuffer(renderbuffer);
throw new InvalidOperationException(
$"multisample resolve framebuffer {width}x{height} is "
+ $"incomplete: {status}");
}
GLHelpers.ThrowOnResourceError(
_gl,
$"create screenshot resolve target {width}x{height}");
_resolveRenderbuffer = renderbuffer;
return framebuffer;
}
public void DeleteResolveTarget(uint framebuffer)
{
_gl.DeleteFramebuffer(framebuffer);
if (_resolveRenderbuffer != 0u)
_gl.DeleteRenderbuffer(_resolveRenderbuffer);
_resolveRenderbuffer = 0u;
}
public void BlitColorNearest(int width, int height)
{
// A blit is subject to the scissor test, so a frame that left a
// scissor rectangle armed would resolve only part of the image.
// This operation states the state it needs and restores it — the
// same self-contained-GL-state rule the render passes follow.
bool scissor = _gl.IsEnabled(EnableCap.ScissorTest);
if (scissor)
_gl.Disable(EnableCap.ScissorTest);
_gl.BlitFramebuffer(
0,
0,
width,
height,
0,
0,
width,
height,
ClearBufferMask.ColorBufferBit,
BlitFramebufferFilter.Nearest);
if (scissor)
_gl.Enable(EnableCap.ScissorTest);
GLHelpers.ThrowOnResourceError(
_gl,
$"resolve default framebuffer {width}x{height}");
}
public unsafe void ReadRgba(int width, int height, byte[] destination)
{
fixed (byte* pointer = destination)
{
_gl.ReadPixels(
0,
0,
(uint)width,
(uint)height,
PixelFormat.Rgba,
PixelType.UnsignedByte,
pointer);
}
}
}
}

View file

@ -1,525 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using AcDream.App.Rendering;
using Silk.NET.Input;
using Silk.NET.GLFW;
using Silk.NET.OpenGL;
using Silk.NET.Windowing;
namespace AcDream.App.Platform;
internal sealed record GraphicalFramebufferCapabilities(
int RedBits,
int GreenBits,
int BlueBits,
int AlphaBits,
int DepthBits,
int StencilBits,
int SampleBuffers,
int Samples,
bool FramebufferSrgbApi);
internal sealed record GraphicalInputCapabilities(
int KeyboardCount,
int MouseCount,
int GamepadCount,
int JoystickCount);
internal sealed record GraphicalWindowCapabilities(
int LogicalWidth,
int LogicalHeight,
int FramebufferWidth,
int FramebufferHeight,
string MonitorName,
double RefreshRateHz,
bool VSync);
internal sealed record GraphicalAudioCapabilities(
bool Requested,
bool Available,
bool PlaybackSubmitted,
bool DisposalComplete,
string Backend);
internal sealed record GraphicalSmokeLifecycleCapabilities(
int OwnedWindowCount,
int OwnedGlApiCount,
int OwnedInputContextCount,
int OwnedAudioEngineCount,
bool ShutdownComplete);
internal sealed record GraphicalFunctionProbeResult(
bool BindlessTexture,
bool ShaderDrawParameters,
bool MultiDrawIndirect,
bool ShaderStorageBuffer,
bool TimerQuery,
bool SrgbFramebuffer,
bool? PersistentBufferStorage,
IReadOnlyList<string> Failures)
{
internal static GraphicalFunctionProbeResult NotRun { get; } = new(
false,
false,
false,
false,
false,
false,
null,
["active OpenGL function probe did not run"]);
}
internal sealed record GraphicalCapabilityRecord(
DateTimeOffset CapturedAtUtc,
string RuntimeIdentifier,
GraphicalHostOperatingSystem OperatingSystem,
GraphicalDisplayProtocol RequestedDisplayProtocol,
GraphicalDisplayProtocol ActiveDisplayProtocol,
string DisplaySelectionReason,
string WindowBackend,
string WindowBackendVersion,
string GlVendor,
string GlRenderer,
string GlVersion,
string GlslVersion,
int GlMajorVersion,
int GlMinorVersion,
int ContextProfileMask,
int ContextFlags,
bool HasBindlessTexture,
bool HasShaderDrawParameters,
bool HasMultiDrawIndirect,
bool HasShaderStorageBuffer,
bool HasBufferStorage,
bool HasTimerQuery,
int MaximumShaderStorageBufferBindings,
int MaximumUniformBufferBindings,
int MaximumTextureSize,
int MaximumArrayTextureLayers,
int MaximumCombinedTextureImageUnits,
GraphicalFramebufferCapabilities Framebuffer,
GraphicalInputCapabilities Input,
GraphicalWindowCapabilities Window,
GraphicalAudioCapabilities Audio,
GraphicalSmokeLifecycleCapabilities Lifecycle,
IReadOnlyList<string> Extensions,
GraphicalFunctionProbeResult FunctionProbe,
IReadOnlyList<string> SupportFailures)
{
internal bool IsSupported => SupportFailures.Count == 0;
}
internal static class GraphicalCapabilityRequirements
{
internal static IReadOnlyList<string> Evaluate(
GraphicalCapabilityRecord capabilities)
{
ArgumentNullException.ThrowIfNull(capabilities);
var failures = new List<string>();
if (capabilities.GlMajorVersion < 4
|| capabilities.GlMajorVersion == 4
&& capabilities.GlMinorVersion < 3)
{
failures.Add(
"OpenGL 4.3 core is required; the active context reports " +
$"{capabilities.GlMajorVersion}.{capabilities.GlMinorVersion}.");
}
if (!capabilities.HasBindlessTexture)
failures.Add("GL_ARB_bindless_texture is required.");
if (!capabilities.HasShaderDrawParameters)
failures.Add("GL_ARB_shader_draw_parameters is required.");
if (!capabilities.HasMultiDrawIndirect)
failures.Add("multi-draw indirect support is required.");
if (!capabilities.HasShaderStorageBuffer)
failures.Add("shader-storage buffers are required.");
if (!capabilities.HasTimerQuery)
failures.Add("OpenGL timer queries are required.");
if (capabilities.Framebuffer.DepthBits < 24)
{
failures.Add(
"the default framebuffer must provide at least 24 depth bits.");
}
if (capabilities.Framebuffer.StencilBits < 8)
{
failures.Add(
"the default framebuffer must provide at least 8 stencil bits.");
}
if (!capabilities.Framebuffer.FramebufferSrgbApi)
failures.Add("framebuffer sRGB support is required.");
if (capabilities.Input.KeyboardCount < 1)
failures.Add("the graphical input backend exposed no keyboard.");
if (capabilities.Input.MouseCount < 1)
failures.Add("the graphical input backend exposed no mouse.");
if (capabilities.FunctionProbe.Failures.Count != 0)
{
failures.AddRange(
capabilities.FunctionProbe.Failures.Select(
failure => $"OpenGL function probe: {failure}"));
}
else
{
if (!capabilities.FunctionProbe.BindlessTexture)
failures.Add("the bindless texture call probe did not pass.");
if (!capabilities.FunctionProbe.ShaderDrawParameters)
{
failures.Add(
"the shader draw-parameters compile probe did not pass.");
}
if (!capabilities.FunctionProbe.MultiDrawIndirect)
failures.Add("the multi-draw indirect call probe did not pass.");
if (!capabilities.FunctionProbe.ShaderStorageBuffer)
failures.Add("the shader-storage buffer call probe did not pass.");
if (!capabilities.FunctionProbe.TimerQuery)
failures.Add("the timer-query call probe did not pass.");
if (!capabilities.FunctionProbe.SrgbFramebuffer)
failures.Add("the sRGB framebuffer call probe did not pass.");
if (capabilities.HasBufferStorage
&& capabilities.FunctionProbe.PersistentBufferStorage != true)
{
failures.Add(
"GL_ARB_buffer_storage was advertised but the persistent " +
"mapping call probe did not pass.");
}
}
return failures;
}
}
internal static class GraphicalCapabilityProbe
{
private const int GlSampleBuffers = 0x80A8;
private const int GlSamples = 0x80A9;
private const int GlMaxShaderStorageBufferBindings = 0x90DD;
private const int GlMaxUniformBufferBindings = 0x8A2F;
private const int GlMaxCombinedTextureImageUnits = 0x8B4D;
internal static GraphicalCapabilityRecord Capture(
GL gl,
IWindow window,
IInputContext input,
GraphicalHostPlatformServices platform)
{
ArgumentNullException.ThrowIfNull(gl);
ArgumentNullException.ThrowIfNull(window);
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(platform);
int major = gl.GetInteger(GetPName.MajorVersion);
int minor = gl.GetInteger(GetPName.MinorVersion);
bool openGl43 = major > 4 || major == 4 && minor >= 3;
bool timerQuery =
major > 3
|| major == 3 && minor >= 3
|| gl.IsExtensionPresent("GL_ARB_timer_query");
bool framebufferSrgb =
major > 3
|| major == 3 && minor >= 0
|| gl.IsExtensionPresent("GL_ARB_framebuffer_sRGB")
|| gl.IsExtensionPresent("GL_EXT_framebuffer_sRGB");
IReadOnlyList<string> extensions = ReadExtensions(gl);
IMonitor? monitor = window.Monitor;
Silk.NET.Windowing.VideoMode mode =
monitor?.VideoMode ?? Silk.NET.Windowing.VideoMode.Default;
var framebuffer = window.FramebufferSize;
var logical = window.Size;
var captured = new GraphicalCapabilityRecord(
DateTimeOffset.UtcNow,
platform.RuntimeIdentifier,
platform.OperatingSystem,
platform.WindowBackend.RequestedProtocol,
GlfwNativePlatformProbe.GetActiveProtocol(
platform.OperatingSystem),
platform.WindowBackend.Reason,
"Silk.NET.Windowing.Glfw",
GlfwNativePlatformProbe.GetVersion(
platform.OperatingSystem),
gl.GetStringS(GLEnum.Vendor),
gl.GetStringS(GLEnum.Renderer),
gl.GetStringS(GLEnum.Version),
gl.GetStringS(GLEnum.ShadingLanguageVersion),
major,
minor,
GetInteger(gl, (GetPName)GLEnum.ContextProfileMask),
GetInteger(gl, (GetPName)GLEnum.ContextFlags),
gl.IsExtensionPresent("GL_ARB_bindless_texture"),
gl.IsExtensionPresent("GL_ARB_shader_draw_parameters"),
openGl43
|| gl.IsExtensionPresent("GL_ARB_multi_draw_indirect"),
openGl43
|| gl.IsExtensionPresent("GL_ARB_shader_storage_buffer_object"),
major > 4
|| major == 4 && minor >= 4
|| gl.IsExtensionPresent("GL_ARB_buffer_storage"),
timerQuery,
GetInteger(
gl,
(GetPName)GlMaxShaderStorageBufferBindings),
GetInteger(gl, (GetPName)GlMaxUniformBufferBindings),
GetInteger(gl, GetPName.MaxTextureSize),
GetInteger(gl, GetPName.MaxArrayTextureLayers),
GetInteger(
gl,
(GetPName)GlMaxCombinedTextureImageUnits),
new GraphicalFramebufferCapabilities(
GetFramebufferAttachmentInteger(
gl,
(FramebufferAttachment)GLEnum.BackLeft,
FramebufferAttachmentParameterName.RedSize),
GetFramebufferAttachmentInteger(
gl,
(FramebufferAttachment)GLEnum.BackLeft,
FramebufferAttachmentParameterName.GreenSize),
GetFramebufferAttachmentInteger(
gl,
(FramebufferAttachment)GLEnum.BackLeft,
FramebufferAttachmentParameterName.BlueSize),
GetFramebufferAttachmentInteger(
gl,
(FramebufferAttachment)GLEnum.BackLeft,
FramebufferAttachmentParameterName.AlphaSize),
GetFramebufferAttachmentInteger(
gl,
(FramebufferAttachment)GLEnum.Depth,
FramebufferAttachmentParameterName.DepthSize),
GetFramebufferAttachmentInteger(
gl,
(FramebufferAttachment)GLEnum.Stencil,
FramebufferAttachmentParameterName.StencilSize),
GetInteger(gl, (GetPName)GlSampleBuffers),
GetInteger(gl, (GetPName)GlSamples),
framebufferSrgb),
new GraphicalInputCapabilities(
input.Keyboards.Count,
input.Mice.Count,
input.Gamepads.Count,
input.Joysticks.Count),
new GraphicalWindowCapabilities(
logical.X,
logical.Y,
framebuffer.X,
framebuffer.Y,
monitor?.Name ?? "unknown",
mode.RefreshRate ?? 0,
window.VSync),
new GraphicalAudioCapabilities(
Requested: false,
Available: false,
PlaybackSubmitted: false,
DisposalComplete: true,
Backend: "not requested"),
new GraphicalSmokeLifecycleCapabilities(
OwnedWindowCount: 1,
OwnedGlApiCount: 1,
OwnedInputContextCount: 1,
OwnedAudioEngineCount: 0,
ShutdownComplete: false),
extensions,
GraphicalFunctionProbeResult.NotRun,
[]);
return captured with
{
SupportFailures =
GraphicalCapabilityRequirements.Evaluate(captured),
};
}
internal static GraphicalCapabilityRecord WithFunctionProbe(
GraphicalCapabilityRecord capabilities,
GraphicalFunctionProbeResult functionProbe)
{
ArgumentNullException.ThrowIfNull(capabilities);
ArgumentNullException.ThrowIfNull(functionProbe);
GraphicalCapabilityRecord updated = capabilities with
{
FunctionProbe = functionProbe,
SupportFailures = [],
};
return updated with
{
SupportFailures =
GraphicalCapabilityRequirements.Evaluate(updated),
};
}
private static int GetInteger(GL gl, GetPName name)
{
return GlResourceCommand.Execute(
gl,
$"query OpenGL integer 0x{(uint)name:X}",
() =>
{
gl.GetInteger(name, out int value);
return value;
});
}
private static int GetFramebufferAttachmentInteger(
GL gl,
FramebufferAttachment attachment,
FramebufferAttachmentParameterName name)
{
return GlResourceCommand.Execute(
gl,
$"query default framebuffer attachment {attachment}/{name}",
() =>
{
gl.GetFramebufferAttachmentParameter(
FramebufferTarget.Framebuffer,
attachment,
name,
out int value);
return value;
});
}
private static IReadOnlyList<string> ReadExtensions(GL gl)
{
int count = GetInteger(gl, GetPName.NumExtensions);
var extensions = new string[Math.Max(0, count)];
for (uint index = 0; index < extensions.Length; index++)
extensions[index] = gl.GetStringS(GLEnum.Extensions, index);
Array.Sort(extensions, StringComparer.Ordinal);
return extensions;
}
}
internal static class GraphicalCapabilityGuard
{
internal static GraphicalCapabilityRecord CaptureVerifyAndWrite(
GL gl,
IWindow window,
IInputContext input,
GraphicalHostPlatformServices platform,
string reportPath)
{
GraphicalCapabilityRecord passive =
GraphicalCapabilityProbe.Capture(
gl,
window,
input,
platform);
GraphicalFunctionProbeResult functions =
GraphicalGlFunctionProbe.Run(gl, passive);
GraphicalCapabilityRecord verified =
GraphicalCapabilityProbe.WithFunctionProbe(
passive,
functions);
GraphicalCapabilityReportWriter.Write(reportPath, verified);
return verified;
}
internal static void ThrowIfUnsupported(
GraphicalCapabilityRecord capabilities,
string reportPath)
{
ArgumentNullException.ThrowIfNull(capabilities);
if (!capabilities.IsSupported)
{
throw new NotSupportedException(
FormatUnsupportedMessage(capabilities, reportPath));
}
}
internal static string FormatUnsupportedMessage(
GraphicalCapabilityRecord capabilities,
string reportPath)
{
ArgumentNullException.ThrowIfNull(capabilities);
ArgumentException.ThrowIfNullOrWhiteSpace(reportPath);
return
"acdream's mandatory modern renderer is unsupported by the " +
"active graphical backend.\n" +
$"Platform: {capabilities.RuntimeIdentifier}, " +
$"{capabilities.ActiveDisplayProtocol}, " +
$"{capabilities.GlVendor} / {capabilities.GlRenderer}, " +
$"{capabilities.GlVersion}\n" +
string.Join(
"\n",
capabilities.SupportFailures.Select(
failure => $" - {failure}")) +
$"\nFull capability report: {Path.GetFullPath(reportPath)}";
}
}
internal static class GraphicalCapabilityReportWriter
{
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
Converters =
{
new JsonStringEnumConverter(),
},
};
internal static void Write(
string path,
GraphicalCapabilityRecord capabilities)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
ArgumentNullException.ThrowIfNull(capabilities);
string fullPath = Path.GetFullPath(path);
string? directory = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
string temporaryPath = fullPath + ".tmp";
File.WriteAllText(
temporaryPath,
JsonSerializer.Serialize(capabilities, Options));
File.Move(temporaryPath, fullPath, overwrite: true);
}
}
internal static unsafe class GlfwNativePlatformProbe
{
private const int GlfwWin32Platform = 0x00060001;
private const int GlfwWaylandPlatform = 0x00060003;
private const int GlfwX11Platform = 0x00060004;
internal static GraphicalDisplayProtocol GetActiveProtocol(
GraphicalHostOperatingSystem operatingSystem)
{
if (operatingSystem == GraphicalHostOperatingSystem.Windows)
return GraphicalDisplayProtocol.Windows;
if (!GraphicalWindowBackendConfigurator.TryGetConfiguredApi(
out Glfw? glfw)
|| glfw is null
|| !glfw.Context.TryGetProcAddress(
"glfwGetPlatform",
out nint export))
{
return GraphicalDisplayProtocol.Unknown;
}
int platform =
((delegate* unmanaged[Cdecl]<int>)export)();
return platform switch
{
GlfwX11Platform => GraphicalDisplayProtocol.X11,
GlfwWaylandPlatform => GraphicalDisplayProtocol.Wayland,
GlfwWin32Platform => GraphicalDisplayProtocol.Windows,
_ => GraphicalDisplayProtocol.Unknown,
};
}
internal static string GetVersion(
GraphicalHostOperatingSystem operatingSystem)
{
if (!GraphicalWindowBackendConfigurator.TryGetConfiguredApi(
out Glfw? glfw)
|| glfw is null)
{
return "unknown";
}
return glfw.GetVersionString() ?? "unknown";
}
}

View file

@ -1,513 +0,0 @@
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
using Silk.NET.OpenGL;
namespace AcDream.App.Platform;
/// <summary>
/// Executes one minimal, ownership-balanced call path for every OpenGL feature
/// the mandatory renderer relies upon. Advertisement alone is insufficient:
/// several Linux driver failures present an extension string while returning
/// a missing entry point, invalid bindless handle, or unusable framebuffer.
/// </summary>
internal static unsafe class GraphicalGlFunctionProbe
{
private const string VertexSource = """
#version 430 core
#extension GL_ARB_shader_draw_parameters : require
void main()
{
float identity =
float(gl_DrawIDARB + gl_BaseInstanceARB) * 0.0;
gl_Position = vec4(identity, 0.0, 0.0, 1.0);
}
""";
private const string FragmentSource = """
#version 430 core
layout(location = 0) out vec4 outColor;
void main()
{
outColor = vec4(1.0);
}
""";
internal static GraphicalFunctionProbeResult Run(
GL gl,
GraphicalCapabilityRecord capabilities)
{
ArgumentNullException.ThrowIfNull(gl);
ArgumentNullException.ThrowIfNull(capabilities);
var failures = new List<string>();
bool bindless = capabilities.HasBindlessTexture
&& Attempt(
"bindless texture handle/residency",
() => ProbeBindless(gl),
failures);
bool shaderDraw = capabilities.HasShaderDrawParameters
&& Attempt(
"shader draw parameters and multi-draw indirect",
() => ProbeShaderDrawAndMultiDraw(gl),
failures);
bool multiDraw = shaderDraw
&& capabilities.HasMultiDrawIndirect;
bool shaderStorage = capabilities.HasShaderStorageBuffer
&& Attempt(
"shader-storage buffer",
() => ProbeShaderStorageBuffer(gl),
failures);
bool timer = capabilities.HasTimerQuery
&& Attempt(
"timer query",
() => ProbeTimerQuery(gl),
failures);
bool srgb = capabilities.Framebuffer.FramebufferSrgbApi
&& Attempt(
"sRGB depth/stencil framebuffer",
() => ProbeSrgbFramebuffer(gl),
failures);
bool? persistent = capabilities.HasBufferStorage
? Attempt(
"persistent coherent buffer storage",
() => ProbePersistentBufferStorage(gl),
failures)
: null;
if (!capabilities.HasBindlessTexture)
failures.Add("GL_ARB_bindless_texture was not advertised.");
if (!capabilities.HasShaderDrawParameters)
failures.Add("GL_ARB_shader_draw_parameters was not advertised.");
if (!capabilities.HasMultiDrawIndirect)
failures.Add("multi-draw indirect was not advertised.");
if (!capabilities.HasShaderStorageBuffer)
failures.Add("shader-storage buffers were not advertised.");
if (!capabilities.HasTimerQuery)
failures.Add("timer queries were not advertised.");
if (!capabilities.Framebuffer.FramebufferSrgbApi)
failures.Add("framebuffer sRGB was not advertised.");
return new GraphicalFunctionProbeResult(
bindless,
shaderDraw,
multiDraw,
shaderStorage,
timer,
srgb,
persistent,
failures);
}
private static bool Attempt(
string name,
Action action,
List<string> failures)
{
try
{
action();
return true;
}
catch (Exception error)
{
failures.Add($"{name}: {error.GetType().Name}: {error.Message}");
return false;
}
}
private static void ProbeBindless(GL gl)
{
if (!BindlessSupport.TryCreate(gl, out BindlessSupport? bindless)
|| bindless is null)
{
throw new NotSupportedException(
"the ARB bindless extension object could not be loaded.");
}
uint texture = GlResourceCommand.CreateTexture(
gl,
"graphical capability bindless texture");
ulong handle = 0;
try
{
byte* pixel = stackalloc byte[4] { 255, 255, 255, 255 };
GlResourceCommand.Execute(
gl,
"initialize graphical capability bindless texture",
() =>
{
gl.BindTexture(TextureTarget.Texture2D, texture);
gl.TexImage2D(
TextureTarget.Texture2D,
0,
InternalFormat.Rgba8,
1,
1,
0,
PixelFormat.Rgba,
PixelType.UnsignedByte,
pixel);
gl.TexParameter(
TextureTarget.Texture2D,
TextureParameterName.TextureMinFilter,
(int)TextureMinFilter.Nearest);
gl.TexParameter(
TextureTarget.Texture2D,
TextureParameterName.TextureMagFilter,
(int)TextureMagFilter.Nearest);
});
handle = bindless.GetResidentHandle(texture);
bindless.MakeNonResident(handle);
handle = 0;
}
finally
{
if (handle != 0)
bindless.MakeNonResident(handle);
gl.BindTexture(TextureTarget.Texture2D, 0);
GlResourceCommand.DeleteTexture(
gl,
texture,
"delete graphical capability bindless texture");
}
}
private static void ProbeShaderDrawAndMultiDraw(GL gl)
{
uint program = ShaderProgramConstruction.Build(
new GlShaderProgramBuildApi(gl),
VertexSource,
FragmentSource);
uint vertexArray = 0;
uint elementBuffer = 0;
uint indirectBuffer = 0;
try
{
vertexArray = GlResourceCommand.CreateName(
gl,
"graphical capability vertex array",
gl.GenVertexArray,
gl.DeleteVertexArray);
elementBuffer = GlResourceCommand.CreateName(
gl,
"graphical capability element buffer",
gl.GenBuffer,
gl.DeleteBuffer);
indirectBuffer = GlResourceCommand.CreateName(
gl,
"graphical capability indirect buffer",
gl.GenBuffer,
gl.DeleteBuffer);
uint* index = stackalloc uint[1] { 0 };
uint* command = stackalloc uint[5]
{
0,
1,
0,
0,
0,
};
GLHelpers.ThrowOnResourceError(
gl,
"execute graphical capability multi-draw indirect call " +
"(precondition)");
gl.UseProgram(program);
gl.BindVertexArray(vertexArray);
gl.BindBuffer(
BufferTargetARB.ElementArrayBuffer,
elementBuffer);
gl.BufferData(
BufferTargetARB.ElementArrayBuffer,
(nuint)sizeof(uint),
index,
BufferUsageARB.StaticDraw);
gl.BindBuffer(
BufferTargetARB.DrawIndirectBuffer,
indirectBuffer);
gl.BufferData(
BufferTargetARB.DrawIndirectBuffer,
(nuint)(sizeof(uint) * 5),
command,
BufferUsageARB.StaticDraw);
gl.MultiDrawElementsIndirect(
PrimitiveType.Triangles,
DrawElementsType.UnsignedInt,
null,
1,
0);
GLHelpers.ThrowOnResourceError(
gl,
"execute graphical capability multi-draw indirect call");
}
finally
{
gl.UseProgram(0);
gl.BindVertexArray(0);
gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, 0);
if (indirectBuffer != 0)
{
GlResourceCommand.DeleteBuffer(
gl,
indirectBuffer,
"delete graphical capability indirect buffer");
}
if (elementBuffer != 0)
{
GlResourceCommand.DeleteBuffer(
gl,
elementBuffer,
"delete graphical capability element buffer");
}
if (vertexArray != 0)
{
GlResourceCommand.DeleteVertexArray(
gl,
vertexArray,
"delete graphical capability vertex array");
}
GlResourceCommand.DeleteProgram(
gl,
program,
"delete graphical capability shader program");
}
}
private static void ProbeShaderStorageBuffer(GL gl)
{
uint buffer = GlResourceCommand.CreateName(
gl,
"graphical capability shader-storage buffer",
gl.GenBuffer,
gl.DeleteBuffer);
try
{
uint* value = stackalloc uint[1] { 0xACD0_0001u };
GLHelpers.ThrowOnResourceError(
gl,
"bind graphical capability shader-storage buffer " +
"(precondition)");
gl.BindBuffer(BufferTargetARB.ShaderStorageBuffer, buffer);
gl.BufferData(
BufferTargetARB.ShaderStorageBuffer,
(nuint)sizeof(uint),
value,
BufferUsageARB.StaticDraw);
gl.BindBufferBase(
BufferTargetARB.ShaderStorageBuffer,
0,
buffer);
gl.BindBufferBase(
BufferTargetARB.ShaderStorageBuffer,
0,
0);
GLHelpers.ThrowOnResourceError(
gl,
"bind graphical capability shader-storage buffer");
}
finally
{
gl.BindBuffer(BufferTargetARB.ShaderStorageBuffer, 0);
GlResourceCommand.DeleteBuffer(
gl,
buffer,
"delete graphical capability shader-storage buffer");
}
}
private static void ProbeTimerQuery(GL gl)
{
uint query = GlResourceCommand.CreateName(
gl,
"graphical capability timer query",
gl.GenQuery,
gl.DeleteQuery);
try
{
GlResourceCommand.Execute(
gl,
"execute graphical capability timer query",
() =>
{
gl.BeginQuery(QueryTarget.TimeElapsed, query);
gl.EndQuery(QueryTarget.TimeElapsed);
});
}
finally
{
GlResourceCommand.Execute(
gl,
"delete graphical capability timer query",
() => gl.DeleteQuery(query));
}
}
private static void ProbeSrgbFramebuffer(GL gl)
{
uint texture = 0;
uint depthStencil = 0;
uint framebuffer = 0;
try
{
texture = GlResourceCommand.CreateTexture(
gl,
"graphical capability sRGB color texture");
depthStencil = GlResourceCommand.CreateName(
gl,
"graphical capability depth/stencil renderbuffer",
gl.GenRenderbuffer,
gl.DeleteRenderbuffer);
framebuffer = GlResourceCommand.CreateName(
gl,
"graphical capability framebuffer",
gl.GenFramebuffer,
gl.DeleteFramebuffer);
GlResourceCommand.Execute(
gl,
"configure graphical capability sRGB framebuffer",
() =>
{
gl.BindTexture(TextureTarget.Texture2D, texture);
gl.TexImage2D(
TextureTarget.Texture2D,
0,
InternalFormat.Srgb8Alpha8,
2,
2,
0,
PixelFormat.Rgba,
PixelType.UnsignedByte,
null);
gl.BindRenderbuffer(
RenderbufferTarget.Renderbuffer,
depthStencil);
gl.RenderbufferStorage(
RenderbufferTarget.Renderbuffer,
InternalFormat.Depth24Stencil8,
2,
2);
gl.BindFramebuffer(
FramebufferTarget.Framebuffer,
framebuffer);
gl.FramebufferTexture2D(
FramebufferTarget.Framebuffer,
FramebufferAttachment.ColorAttachment0,
TextureTarget.Texture2D,
texture,
0);
gl.FramebufferRenderbuffer(
FramebufferTarget.Framebuffer,
FramebufferAttachment.DepthStencilAttachment,
RenderbufferTarget.Renderbuffer,
depthStencil);
if (gl.CheckFramebufferStatus(
FramebufferTarget.Framebuffer)
!= GLEnum.FramebufferComplete)
{
throw new InvalidOperationException(
"the sRGB depth/stencil framebuffer is incomplete.");
}
gl.Enable(EnableCap.FramebufferSrgb);
gl.Clear(
ClearBufferMask.ColorBufferBit
| ClearBufferMask.DepthBufferBit
| ClearBufferMask.StencilBufferBit);
gl.Disable(EnableCap.FramebufferSrgb);
});
}
finally
{
gl.Disable(EnableCap.FramebufferSrgb);
gl.BindFramebuffer(FramebufferTarget.Framebuffer, 0);
gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, 0);
gl.BindTexture(TextureTarget.Texture2D, 0);
if (framebuffer != 0)
{
GlResourceCommand.Execute(
gl,
"delete graphical capability framebuffer",
() => gl.DeleteFramebuffer(framebuffer));
}
if (depthStencil != 0)
{
GlResourceCommand.Execute(
gl,
"delete graphical capability depth/stencil renderbuffer",
() => gl.DeleteRenderbuffer(depthStencil));
}
if (texture != 0)
{
GlResourceCommand.DeleteTexture(
gl,
texture,
"delete graphical capability sRGB texture");
}
}
}
private static void ProbePersistentBufferStorage(GL gl)
{
uint buffer = GlResourceCommand.CreateName(
gl,
"graphical capability persistent buffer",
gl.GenBuffer,
gl.DeleteBuffer);
void* mapped = null;
try
{
GlResourceCommand.Execute(
gl,
"allocate graphical capability persistent buffer",
() =>
{
gl.BindBuffer(BufferTargetARB.ArrayBuffer, buffer);
gl.BufferStorage(
GLEnum.ArrayBuffer,
64,
null,
(uint)(
BufferStorageMask.MapWriteBit
| BufferStorageMask.MapPersistentBit
| BufferStorageMask.MapCoherentBit
| BufferStorageMask.DynamicStorageBit));
mapped = gl.MapBufferRange(
BufferTargetARB.ArrayBuffer,
0,
64,
MapBufferAccessMask.WriteBit
| MapBufferAccessMask.PersistentBit
| MapBufferAccessMask.CoherentBit);
if (mapped is null)
{
throw new InvalidOperationException(
"persistent mapping returned a null pointer.");
}
*((byte*)mapped) = 0x5A;
});
}
finally
{
if (mapped is not null)
{
GlResourceCommand.Execute(
gl,
"unmap graphical capability persistent buffer",
() => gl.UnmapBuffer(BufferTargetARB.ArrayBuffer));
}
gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
GlResourceCommand.DeleteBuffer(
gl,
buffer,
"delete graphical capability persistent buffer");
}
}
}

View file

@ -187,3 +187,58 @@ internal static class GraphicalWindowBackendConfigurator
DefaultPathResolver.BaseDirectoryResolver);
}
}
/// <summary>
/// Reads the GLFW platform actually selected at runtime (as opposed to the one
/// requested — GLFW's <c>Automatic</c> hint can resolve to either X11 or
/// Wayland). Moved here from the deleted (Campaign V slice V11) raw-GL
/// <c>GraphicalCapabilityRecord.cs</c>: <see cref="VulkanGraphicsContext"/>
/// depends on this for its own capability report, so it survived the GL arm
/// that used to sit alongside it.
/// </summary>
internal static unsafe class GlfwNativePlatformProbe
{
private const int GlfwWin32Platform = 0x00060001;
private const int GlfwWaylandPlatform = 0x00060003;
private const int GlfwX11Platform = 0x00060004;
internal static GraphicalDisplayProtocol GetActiveProtocol(
GraphicalHostOperatingSystem operatingSystem)
{
if (operatingSystem == GraphicalHostOperatingSystem.Windows)
return GraphicalDisplayProtocol.Windows;
if (!GraphicalWindowBackendConfigurator.TryGetConfiguredApi(
out Glfw? glfw)
|| glfw is null
|| !glfw.Context.TryGetProcAddress(
"glfwGetPlatform",
out nint export))
{
return GraphicalDisplayProtocol.Unknown;
}
int platform =
((delegate* unmanaged[Cdecl]<int>)export)();
return platform switch
{
GlfwX11Platform => GraphicalDisplayProtocol.X11,
GlfwWaylandPlatform => GraphicalDisplayProtocol.Wayland,
GlfwWin32Platform => GraphicalDisplayProtocol.Windows,
_ => GraphicalDisplayProtocol.Unknown,
};
}
internal static string GetVersion(
GraphicalHostOperatingSystem operatingSystem)
{
if (!GraphicalWindowBackendConfigurator.TryGetConfiguredApi(
out Glfw? glfw)
|| glfw is null)
{
return "unknown";
}
return glfw.GetVersionString() ?? "unknown";
}
}

View file

@ -1,28 +0,0 @@
namespace AcDream.App;
/// <summary>
/// Campaign V slice V5: which rendering backend the graphical host starts.
///
/// Slice V10 flipped the default. <see cref="Vulkan"/> is what an unset
/// <c>ACDREAM_RENDER_BACKEND</c> now selects; <see cref="Gl"/> remains reachable
/// for one slice by setting that variable to <c>gl</c>, and slice V11 deletes it
/// along with the escape hatch.
///
/// This enum is public only because <see cref="RuntimeOptions"/> is public and
/// exposes it as a property. The backend-neutral
/// <c>AcDream.App.Rendering.Gpu.GpuBackendKind</c> stays internal and describes a
/// live device rather than a startup request; the two are deliberately separate
/// because a Vulkan-requested process can still fail its capability gate and
/// never own a Vulkan device at all.
/// </summary>
public enum RenderBackendKind
{
/// <summary>
/// OpenGL 4.3 core + bindless + MDI. The escape hatch after slice V10,
/// reachable only by <c>ACDREAM_RENDER_BACKEND=gl</c>, deleted at V11.
/// </summary>
Gl,
/// <summary>Vulkan 1.3 core. The default backend as of Campaign V slice V10.</summary>
Vulkan,
}

View file

@ -1,68 +0,0 @@
using System.Runtime.ExceptionServices;
namespace AcDream.App.Rendering;
/// <summary>
/// Temporarily removes bindless residency around texture-state mutation while
/// retaining the obligation to restore the pair across failed mutation or
/// failed reacquisition attempts.
/// </summary>
internal sealed class BindlessTextureMutationGuard(BindlessTexturePair pair)
{
private bool _restoreRequired;
private bool _operationActive;
internal bool RestoreRequired => _restoreRequired;
public void Execute(Action mutation)
{
ArgumentNullException.ThrowIfNull(mutation);
if (_operationActive)
throw new InvalidOperationException("A bindless texture mutation is already active.");
_operationActive = true;
Exception? mutationFailure = null;
Exception? restoreFailure = null;
try
{
_restoreRequired |= pair.HasAnyResident;
if (pair.HasAnyResident)
pair.Release();
mutation();
}
catch (Exception failure)
{
mutationFailure = failure;
}
finally
{
if (_restoreRequired)
{
try
{
_ = pair.Acquire();
_restoreRequired = false;
}
catch (Exception failure)
{
restoreFailure = failure;
}
}
_operationActive = false;
}
if (mutationFailure is not null && restoreFailure is not null)
{
throw new AggregateException(
"Texture-state mutation failed and bindless residency could not be restored.",
mutationFailure,
restoreFailure);
}
if (mutationFailure is not null)
ExceptionDispatchInfo.Capture(mutationFailure).Throw();
if (restoreFailure is not null)
ExceptionDispatchInfo.Capture(restoreFailure).Throw();
}
}

View file

@ -1,94 +0,0 @@
namespace AcDream.App.Rendering;
/// <summary>
/// Owns the two independent residency edges used by TerrainAtlas. Acquisition
/// and release publish each successful edge immediately, so a partial failure
/// retries only work that is still pending.
/// </summary>
internal sealed class BindlessTexturePair(
uint firstTexture,
uint secondTexture,
Func<uint, ulong> acquire,
Action<ulong> release)
{
private ulong _firstHandle;
private ulong _secondHandle;
private bool _firstResident;
private bool _secondResident;
public bool IsFullyResident => _firstResident && _secondResident;
public bool HasAnyResident => _firstResident || _secondResident;
public (ulong First, ulong Second) Acquire()
{
if (!_firstResident)
{
_firstHandle = acquire(firstTexture);
_firstResident = true;
}
if (!_secondResident)
{
try
{
_secondHandle = acquire(secondTexture);
_secondResident = true;
}
catch (Exception acquisitionFailure)
{
try
{
Release();
}
catch (Exception cleanupFailure)
{
throw new AggregateException(
"Second bindless texture acquisition failed and the resident prefix did not cleanly roll back.",
acquisitionFailure,
cleanupFailure);
}
throw;
}
}
return (_firstHandle, _secondHandle);
}
public void Release()
{
List<Exception>? failures = null;
if (_firstResident)
{
try
{
release(_firstHandle);
_firstResident = false;
_firstHandle = 0;
}
catch (Exception failure)
{
(failures ??= []).Add(failure);
}
}
if (_secondResident)
{
try
{
release(_secondHandle);
_secondResident = false;
_secondHandle = 0;
}
catch (Exception failure)
{
(failures ??= []).Add(failure);
}
}
if (failures is not null)
throw new AggregateException(
"One or more bindless texture handles could not be made non-resident.",
failures);
}
}

View file

@ -1,8 +1,8 @@
// ClipFrame.cs
//
// Phase U.3: the GPU-side container + uploader for the SHARED per-frame clip
// data consumed by mesh_modern.vert (SSBO binding=2) and terrain_modern.vert
// (UBO binding=2). This is the "shared" half of the U.3 clip mechanism; the
// Phase U.3: the per-frame container for the SHARED per-frame clip data
// consumed by mesh_modern.vert (SSBO binding=2) and terrain_modern.vert (UBO
// binding=2). This is the "shared" half of the U.3 clip mechanism; the
// per-instance slot index buffer (SSBO binding=3) is PER-RENDERER and owned by
// each renderer (WbDrawDispatcher / EnvCellRenderer), parallel to its instance
// buffer — it is NOT here.
@ -22,21 +22,23 @@
// terrain OutsideView planes, then points each renderer's per-instance slot
// buffer at the right slots.
//
// Pure CPU byte-packing + a thin GL upload. The byte layout is asserted by
// ClipFrameLayoutTests so a silent
// std430/std140 drift can't reach the GPU.
// Pure CPU byte-packing. The GL upload machinery this file used to carry
// alongside the packing (a per-flight-slot region SSBO + terrain UBO arena,
// reservation/upload-once bookkeeping, and their disposal) was deleted at
// Campaign V slice V11: the RHI arm (see RhiWorldPassSurface.PrepareClipFrame /
// SetTerrainClip in WorldPassSurface.cs) reads RegionBytes/TerrainBytes below and
// copies them into a frame ring allocation instead, so nothing here owns a GPU
// resource anymore. The byte layout is asserted by ClipFrameLayoutTests so a
// silent std430/std140 drift can't reach the GPU.
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Wb;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Per-frame container + uploader for the SHARED clip data: the binding=2 mesh
/// SSBO (one <c>CellClip</c> per slot, slot 0 reserved no-clip) and the binding=2
/// terrain UBO (the single OutsideView region). See the file header for the exact
/// Per-frame container for the SHARED clip data: the binding=2 mesh SSBO (one
/// <c>CellClip</c> per slot, slot 0 reserved no-clip) and the binding=2 terrain
/// UBO (the single OutsideView region). See the file header for the exact
/// std430 / std140 byte layout. Per-instance slot buffers (binding=3) are owned by
/// each renderer, not here.
/// </summary>
@ -61,13 +63,12 @@ public sealed class ClipFrame : IDisposable
/// coincidence of the 16-byte vec4 rule, but a DIFFERENT layout family.</summary>
public const int TerrainUboBytes = 16 + MaxPlanes * 16; // 144
/// <summary>SSBO binding index for the shared per-cell clip regions
/// (mesh_modern.vert binding=2).</summary>
public const uint MeshClipSsboBinding = 2;
/// <summary>UBO binding index for the terrain OutsideView clip region
/// (terrain_modern.vert binding=2). UBO namespace — distinct from the SSBO
/// binding=2 above.</summary>
/// (terrain_modern.vert binding=2). Read directly by both the RHI world-pass
/// section binder (<c>WorldFrameSectionBinding.BindTerrainClip</c>) and
/// <c>PortalDepthMaskRenderer</c>, so unlike the mesh SSBO binding (which the
/// RHI arm addresses through its own <c>GpuBindingModel.StorageClipRegions</c>
/// instead) this one is still genuinely shared.</summary>
public const uint TerrainClipUboBinding = 2;
// ---- CPU-side state ------------------------------------------------------
@ -79,55 +80,14 @@ public sealed class ClipFrame : IDisposable
// Packed std140 bytes for the terrain UBO (always TerrainUboBytes long).
private readonly byte[] _terrainBytes = new byte[TerrainUboBytes];
// ---- GL-side state (lazily created on first upload) ----------------------
private uint _regionSsbo;
private uint _terrainUbo;
private RetryableResourceReleaseLedger? _disposeResources;
private bool _disposing;
private bool _disposed;
internal const int FrameSlotCount = GpuFrameFlightController.DefaultMaximumFramesInFlight;
private sealed class RegionBuffer
{
public uint Name;
public int CapacityBytes;
public ClipBufferCapacityPolicy CapacityPolicy;
}
private sealed class TerrainBufferArena
{
public uint Name;
public int CapacityBytes;
public ClipBufferCapacityPolicy CapacityPolicy;
}
// A full clip-region table is immutable once ClipFrameAssembler has packed
// the frame. Keep exactly one SSBO per GPU-fenced frame slot. Terrain clip
// state changes between outside-view slices, so each frame slot owns one
// UBO arena and each draw binds a distinct aligned range within that arena.
// This bounds native object retention at six buffers total instead of one
// region/terrain pair per outside-view slice.
private readonly ClipFrameResourceRing<RegionBuffer> _regionBuffers =
new(FrameSlotCount);
private readonly ClipFrameResourceRing<TerrainBufferArena> _terrainBuffers =
new(FrameSlotCount);
private readonly ClipFrameUploadState _uploadState = new();
private int _dynamicFrameSlot;
private bool _dynamicFrameStarted;
private int _terrainRecordStrideBytes;
private TerrainClipBufferBinding _terrainBinding;
internal int DynamicBufferSetCount =>
Math.Max(_regionBuffers.Count, _terrainBuffers.Count);
internal int RegionBufferCount => _regionBuffers.Count;
internal int TerrainBufferCount => _terrainBuffers.Count;
internal int TerrainRecordStrideBytes => _terrainRecordStrideBytes;
// GL reference captured on the first upload so Dispose can delete each
// frame-slot buffer. ClipFrame is long-lived and owns their teardown.
private GL? _gl;
/// <summary>
/// The GL arm's per-flight-slot region/terrain buffer rings this used to
/// report on were deleted at Campaign V slice V11: every publication now
/// comes from the current frame's own ring allocator (see
/// <c>RhiWorldPassSurface.Publish</c>), which lives until its frame retires,
/// so there is no persistent dynamic-buffer pool left to size.
/// </summary>
internal int DynamicBufferSetCount => 0;
private ClipFrame(byte[] regionBytes, int slotCount)
{
@ -156,9 +116,10 @@ public sealed class ClipFrame : IDisposable
/// <summary>
/// Phase U.4: reset this frame back to the NoClip state — exactly slot 0
/// (no-clip, count 0) and a terrain count of 0 — WITHOUT allocating a new
/// frame or new GL buffers. The single long-lived <c>_clipFrame</c> in
/// GameWindow is reset + re-packed every frame by <see cref="ClipFrameAssembler"/>,
/// then uploaded through one SSBO and one terrain arena per fenced frame slot.
/// frame. The single long-lived <c>_clipFrame</c> in GameWindow is reset +
/// re-packed every frame by <see cref="ClipFrameAssembler"/>, then published
/// through one frame-ring allocation per section (see
/// <c>RhiWorldPassSurface.PrepareClipFrame</c>).
/// </summary>
public void Reset()
{
@ -175,35 +136,18 @@ public sealed class ClipFrame : IDisposable
Array.Clear(_terrainBytes);
}
/// <summary>The shared mesh-clip SSBO id, or 0 before the first
/// <see cref="UploadRegions"/>. Renderers may bind this directly if they don't
/// receive it via a parameter; <see cref="UploadRegions"/> already binds it to
/// <see cref="MeshClipSsboBinding"/>.</summary>
public uint RegionSsbo => _regionSsbo;
/// <summary>The terrain-clip UBO id, or 0 before the first
/// <see cref="UploadTerrainClip"/>. The buffer alone does not identify the
/// active range; consumers should use <see cref="TerrainBinding"/>.</summary>
public uint TerrainUbo => _terrainUbo;
/// <summary>The currently installed terrain-clip range. The buffer name is
/// stable for the current GPU-fenced frame slot; the offset advances once
/// for every outside-view draw.</summary>
public TerrainClipBufferBinding TerrainBinding => _terrainBinding;
/// <summary>
/// Selects one GPU-fenced frame slot. Its region SSBO and terrain arena are
/// safe to reuse because the frame-flight controller already waited for the
/// slot's preceding submission.
/// Per-frame hook kept for the shared render-frame-begin ordering — it is
/// called once per GPU-fenced frame slot alongside every other renderer's
/// <c>BeginFrame</c>, regardless of backend. The GL arm's frame-slot-scoped
/// region SSBO + terrain UBO arena this used to select was deleted at
/// Campaign V slice V11: the RHI arm takes a fresh ring allocation from the
/// current GPU frame for every publication instead, so there is no
/// frame-slot state left here to reset — only the argument to validate.
/// </summary>
public void BeginFrame(int frameSlot)
{
if ((uint)frameSlot >= FrameSlotCount)
throw new ArgumentOutOfRangeException(nameof(frameSlot));
_dynamicFrameSlot = frameSlot;
_dynamicFrameStarted = true;
_terrainBinding = default;
_uploadState.BeginFrame();
ArgumentOutOfRangeException.ThrowIfNegative(frameSlot);
}
/// <summary>
@ -272,266 +216,14 @@ public sealed class ClipFrame : IDisposable
}
/// <summary>
/// Compatibility entry point for one-region/one-terrain callers. Complex
/// PView frames should reserve their complete terrain sequence, upload the
/// regions once, then call <see cref="UploadTerrainClip"/> per slice.
/// No-op: this container owns no GPU resource of its own on the RHI arm —
/// the deleted GL arm's per-frame-slot region SSBO + terrain UBO arena was
/// the only thing to release. Kept as a method (and <see cref="ClipFrame"/>
/// kept as <see cref="IDisposable"/>) so <c>GameWindowLifetime</c>'s ordered
/// shutdown doesn't need a special case for this one resource.
/// </summary>
public unsafe void UploadShared(GL gl)
{
ReserveTerrainUploads(gl, 1);
UploadRegions(gl);
UploadTerrainClip(gl);
}
/// <summary>
/// Allocates the current frame slot's terrain arena before any draw uses it.
/// The arena has one alignment-safe range per subsequent
/// <see cref="UploadTerrainClip"/> call, so later slices never overwrite
/// uniform data referenced by earlier GPU commands.
/// </summary>
public void ReserveTerrainUploads(GL gl, int uploadCount)
{
ValidateGlContext(gl);
ArgumentOutOfRangeException.ThrowIfLessThan(uploadCount, 1);
_uploadState.ValidateTerrainReservation(uploadCount);
if (_terrainRecordStrideBytes == 0)
{
GLHelpers.ThrowOnResourceError(gl, "ClipFrame terrain alignment query (precondition)");
gl.GetInteger(GetPName.UniformBufferOffsetAlignment, out int alignment);
GLHelpers.ThrowOnResourceError(gl, "ClipFrame terrain alignment query");
_terrainRecordStrideBytes = ClipFrameArenaLayout.RecordStride(alignment);
}
TerrainBufferArena arena = GetOrCreateTerrainArena(gl);
int requiredBytes = ClipFrameArenaLayout.RequiredBytes(
uploadCount,
_terrainRecordStrideBytes);
int targetCapacity = arena.CapacityPolicy.SelectCapacity(
arena.CapacityBytes,
requiredBytes);
if (targetCapacity != arena.CapacityBytes)
{
ClipBufferCapacityTransaction.Resize(
ref arena.CapacityBytes,
targetCapacity,
(previousBytes, newBytes) =>
TrackedGlResource.AllocateBufferStorage(
gl,
GLEnum.UniformBuffer,
arena.Name,
previousBytes,
newBytes,
GLEnum.DynamicDraw,
"ClipFrame terrain UBO arena resize"));
}
_terrainUbo = arena.Name;
_uploadState.CommitTerrainReservation(uploadCount);
}
/// <summary>Uploads and binds the immutable clip-region table once for the
/// assembled frame.</summary>
public unsafe void UploadRegions(GL gl)
{
ValidateGlContext(gl);
_uploadState.ValidateRegionsNotUploaded();
RegionBuffer region = GetOrCreateRegionBuffer(gl);
int regionByteCount = checked(_slotCount * CellClipStrideBytes);
int targetCapacity = region.CapacityPolicy.SelectCapacity(
region.CapacityBytes,
regionByteCount);
if (targetCapacity != region.CapacityBytes)
{
ClipBufferCapacityTransaction.Resize(
ref region.CapacityBytes,
targetCapacity,
(previousBytes, newBytes) =>
TrackedGlResource.AllocateBufferStorage(
gl,
GLEnum.ShaderStorageBuffer,
region.Name,
previousBytes,
newBytes,
GLEnum.DynamicDraw,
"ClipFrame region SSBO resize"));
}
fixed (byte* p = _regionBytes)
{
TrackedGlResource.UpdateBufferSubData(
gl,
GLEnum.ShaderStorageBuffer,
region.Name,
0,
regionByteCount,
p,
"ClipFrame region SSBO update");
}
gl.BindBufferBase(
BufferTargetARB.ShaderStorageBuffer,
MeshClipSsboBinding,
region.Name);
GLHelpers.ThrowOnResourceError(gl, "ClipFrame region SSBO binding");
_regionSsbo = region.Name;
_uploadState.MarkRegionsUploaded();
}
/// <summary>Uploads the current terrain clip bytes into the next unique
/// aligned arena record and installs that range at UBO binding 2.</summary>
public unsafe TerrainClipBufferBinding UploadTerrainClip(GL gl)
{
ValidateGlContext(gl);
int recordIndex = _uploadState.NextTerrainRecord();
TerrainBufferArena arena = _terrainBuffers.GetRequired(_dynamicFrameSlot);
int offsetBytes = ClipFrameArenaLayout.RecordOffset(
recordIndex,
_terrainRecordStrideBytes);
fixed (byte* p = _terrainBytes)
{
TrackedGlResource.UpdateBufferSubData(
gl,
GLEnum.UniformBuffer,
arena.Name,
offsetBytes,
TerrainUboBytes,
p,
"ClipFrame terrain UBO range update");
}
_terrainBinding = new TerrainClipBufferBinding(
arena.Name,
offsetBytes,
TerrainUboBytes);
_terrainBinding.Bind(gl);
_terrainUbo = arena.Name;
return _terrainBinding;
}
public void BindTerrainClip(GL gl)
{
ValidateGlContext(gl);
if (!_terrainBinding.IsValid)
throw new InvalidOperationException("No terrain clip range has been uploaded for this frame.");
_terrainBinding.Bind(gl);
}
private RegionBuffer GetOrCreateRegionBuffer(GL gl)
{
if (_regionBuffers.TryGet(_dynamicFrameSlot, out RegionBuffer? existing))
return existing!;
uint name = TrackedGlResource.CreateBuffer(gl, "ClipFrame region SSBO creation");
var created = new RegionBuffer { Name = name };
try
{
_regionBuffers.Set(_dynamicFrameSlot, created);
return created;
}
catch
{
TrackedGlResource.DeleteBuffer(gl, name, 0, "ClipFrame region SSBO rollback");
throw;
}
}
private TerrainBufferArena GetOrCreateTerrainArena(GL gl)
{
if (_terrainBuffers.TryGet(_dynamicFrameSlot, out TerrainBufferArena? existing))
return existing!;
uint name = TrackedGlResource.CreateBuffer(gl, "ClipFrame terrain UBO creation");
var created = new TerrainBufferArena { Name = name };
try
{
_terrainBuffers.Set(_dynamicFrameSlot, created);
return created;
}
catch
{
TrackedGlResource.DeleteBuffer(gl, name, 0, "ClipFrame terrain UBO rollback");
throw;
}
}
private void ValidateGlContext(GL gl)
{
ArgumentNullException.ThrowIfNull(gl);
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_dynamicFrameStarted)
throw new InvalidOperationException("BeginFrame must be called before uploading clip data.");
if (_gl is null)
_gl = gl;
else if (!ReferenceEquals(_gl, gl))
throw new InvalidOperationException("ClipFrame cannot span OpenGL contexts.");
}
public void Dispose()
{
if (_disposed || _disposing) return;
_disposing = true;
try
{
if (_disposeResources is null)
{
var releases = new List<(string Name, Action Release)>();
if (_gl is not null)
{
int regionIndex = 0;
foreach (RegionBuffer set in _regionBuffers.Values)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
set.Name,
set.CapacityBytes,
"ClipFrame region SSBO disposal");
releases.Add(($"region-ssbo-{regionIndex++}", release.Run));
}
int terrainIndex = 0;
foreach (TerrainBufferArena arena in _terrainBuffers.Values)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
arena.Name,
arena.CapacityBytes,
"ClipFrame terrain UBO disposal");
releases.Add(($"terrain-ubo-{terrainIndex++}", release.Run));
}
}
_disposeResources = new RetryableResourceReleaseLedger(releases);
}
ResourceReleaseAttempt attempt = _disposeResources.Advance();
if (!_disposeResources.IsComplete)
{
throw attempt.ToException(
"One or more ClipFrame GPU buffers could not be released.");
}
_regionSsbo = 0;
_terrainUbo = 0;
_terrainBinding = default;
_dynamicFrameStarted = false;
_disposeResources = null;
_disposed = true;
if (attempt.HasFailures)
{
throw attempt.ToException(
"ClipFrame GPU buffers released with exceptional committed outcomes.");
}
}
finally
{
_disposing = false;
}
}
// ---- byte helpers (little-endian; matches x86/x64 GPU upload) ------------
@ -573,11 +265,11 @@ public sealed class ClipFrame : IDisposable
/// <summary>
/// The packed std430 region table for slots 0..<see cref="SlotCount"/>-1.
///
/// <para>Campaign V slice V6j: the GL arm hands these to
/// <c>glBufferSubData</c> against a renderer-owned SSBO; the RHI arm copies
/// them straight into a frame ring slice and publishes the range. Same bytes,
/// two destinations — which is why the packing above is backend-neutral and
/// stays where it is.</para>
/// <para>The raw-GL arm this used to feed via <c>glBufferSubData</c> against
/// a renderer-owned SSBO was deleted at Campaign V slice V11; the RHI arm
/// (<c>RhiWorldPassSurface.PrepareClipFrame</c>) now copies these bytes
/// straight into a frame ring slice and publishes the range. The packing
/// above stays backend-neutral regardless.</para>
/// </summary>
internal ReadOnlySpan<byte> RegionBytes =>
_regionBytes.AsSpan(0, _slotCount * CellClipStrideBytes);
@ -594,230 +286,3 @@ public sealed class ClipFrame : IDisposable
/// <summary>Test seam: the packed std140 terrain UBO bytes.</summary>
internal ReadOnlySpan<byte> TerrainBytesForTest => TerrainBytes;
}
/// <summary>A single std140 terrain-clip record within a frame-slot UBO arena.</summary>
public readonly record struct TerrainClipBufferBinding(
uint Buffer,
int OffsetBytes,
int SizeBytes)
{
public bool IsValid => Buffer != 0 && OffsetBytes >= 0 && SizeBytes > 0;
public void Bind(GL gl)
{
ArgumentNullException.ThrowIfNull(gl);
if (!IsValid)
throw new InvalidOperationException("Cannot bind an empty terrain clip range.");
gl.BindBufferRange(
BufferTargetARB.UniformBuffer,
ClipFrame.TerrainClipUboBinding,
Buffer,
(nint)OffsetBytes,
(nuint)SizeBytes);
GLHelpers.ThrowOnResourceError(gl, "ClipFrame terrain UBO range binding");
}
}
internal static class ClipFrameArenaLayout
{
public static int RecordStride(int uniformBufferOffsetAlignment)
{
ArgumentOutOfRangeException.ThrowIfLessThan(uniformBufferOffsetAlignment, 1);
long stride = ((long)ClipFrame.TerrainUboBytes + uniformBufferOffsetAlignment - 1L)
/ uniformBufferOffsetAlignment
* uniformBufferOffsetAlignment;
if (stride > int.MaxValue)
throw new OverflowException("Terrain clip UBO record stride exceeds Int32.MaxValue.");
return (int)stride;
}
public static int RecordOffset(int recordIndex, int recordStrideBytes)
{
ArgumentOutOfRangeException.ThrowIfNegative(recordIndex);
ArgumentOutOfRangeException.ThrowIfLessThan(recordStrideBytes, ClipFrame.TerrainUboBytes);
return checked(recordIndex * recordStrideBytes);
}
public static int RequiredBytes(int recordCount, int recordStrideBytes)
{
ArgumentOutOfRangeException.ThrowIfLessThan(recordCount, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(recordStrideBytes, ClipFrame.TerrainUboBytes);
return checked(recordCount * recordStrideBytes);
}
}
internal static class ClipBufferCapacityTransaction
{
/// <summary>Publishes capacity only after BufferData succeeds, and before
/// any later upload/bind operation can throw.</summary>
public static void Resize(
ref int publishedCapacityBytes,
int targetCapacityBytes,
Action<int, int> allocate)
{
ArgumentOutOfRangeException.ThrowIfNegative(publishedCapacityBytes);
ArgumentOutOfRangeException.ThrowIfLessThan(targetCapacityBytes, 1);
ArgumentNullException.ThrowIfNull(allocate);
int previousCapacityBytes = publishedCapacityBytes;
allocate(previousCapacityBytes, targetCapacityBytes);
publishedCapacityBytes = targetCapacityBytes;
}
}
/// <summary>
/// Growth is immediate; shrinking requires repeated severe under-use. This
/// avoids reallocating during ordinary portal-count jitter while ensuring a
/// one-off pathological flood does not permanently pin its peak GPU storage.
/// </summary>
internal struct ClipBufferCapacityPolicy
{
internal const int ShrinkAfterUnderusedFrames = 3;
internal const int ShrinkUtilizationDivisor = 4;
private int _underusedFrames;
public int SelectCapacity(int currentBytes, int requiredBytes)
{
ArgumentOutOfRangeException.ThrowIfNegative(currentBytes);
ArgumentOutOfRangeException.ThrowIfLessThan(requiredBytes, 1);
if (requiredBytes > currentBytes)
{
_underusedFrames = 0;
return DynamicBufferCapacity.Grow(currentBytes, requiredBytes);
}
if ((long)requiredBytes * ShrinkUtilizationDivisor <= currentBytes)
{
_underusedFrames++;
if (_underusedFrames >= ShrinkAfterUnderusedFrames)
{
_underusedFrames = 0;
return DynamicBufferCapacity.Grow(0, requiredBytes);
}
}
else
{
_underusedFrames = 0;
}
return currentBytes;
}
}
/// <summary>Fixed-cardinality resource ownership indexed by the same frame
/// slots protected by <see cref="GpuFrameFlightController"/>.</summary>
internal sealed class ClipFrameResourceRing<T>(int slotCount) where T : class
{
private readonly T?[] _slots = new T?[slotCount > 0
? slotCount
: throw new ArgumentOutOfRangeException(nameof(slotCount))];
public int Count { get; private set; }
public bool TryGet(int slot, out T? resource)
{
ValidateSlot(slot);
resource = _slots[slot];
return resource is not null;
}
public T GetRequired(int slot)
{
ValidateSlot(slot);
return _slots[slot]
?? throw new InvalidOperationException($"Frame slot {slot} has no resource.");
}
public void Set(int slot, T resource)
{
ValidateSlot(slot);
ArgumentNullException.ThrowIfNull(resource);
if (_slots[slot] is not null)
throw new InvalidOperationException($"Frame slot {slot} already owns a resource.");
_slots[slot] = resource;
Count++;
}
public IEnumerable<T> Values
{
get
{
for (int i = 0; i < _slots.Length; i++)
if (_slots[i] is T value)
yield return value;
}
}
private void ValidateSlot(int slot)
{
if ((uint)slot >= (uint)_slots.Length)
throw new ArgumentOutOfRangeException(nameof(slot));
}
}
/// <summary>Pure sequencing state for one ClipFrame render submission.</summary>
internal sealed class ClipFrameUploadState
{
private bool _frameStarted;
private bool _regionsUploaded;
private int _terrainReserved;
private int _terrainCursor;
public int TerrainReserved => _terrainReserved;
public int TerrainUploaded => _terrainCursor;
public bool RegionsUploaded => _regionsUploaded;
public void BeginFrame()
{
_frameStarted = true;
_regionsUploaded = false;
_terrainReserved = 0;
_terrainCursor = 0;
}
public void ValidateRegionsNotUploaded()
{
EnsureFrameStarted();
if (_regionsUploaded)
throw new InvalidOperationException("Clip regions may be uploaded only once per frame.");
}
public void MarkRegionsUploaded()
{
ValidateRegionsNotUploaded();
_regionsUploaded = true;
}
public void ValidateTerrainReservation(int uploadCount)
{
EnsureFrameStarted();
ArgumentOutOfRangeException.ThrowIfLessThan(uploadCount, 1);
if (_terrainReserved != 0)
throw new InvalidOperationException("Terrain uploads have already been reserved for this frame.");
}
public void CommitTerrainReservation(int uploadCount)
{
ValidateTerrainReservation(uploadCount);
_terrainReserved = uploadCount;
}
public int NextTerrainRecord()
{
EnsureFrameStarted();
if (_terrainReserved == 0)
throw new InvalidOperationException("ReserveTerrainUploads must run before terrain clip uploads.");
if (_terrainCursor >= _terrainReserved)
throw new InvalidOperationException("Terrain clip uploads exceeded the reserved arena record count.");
return _terrainCursor++;
}
private void EnsureFrameStarted()
{
if (!_frameStarted)
throw new InvalidOperationException("BeginFrame must be called before uploading clip data.");
}
}

View file

@ -1,8 +1,6 @@
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Gl;
using AcDream.Core.Textures;
using AcDream.Core.World;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
@ -163,192 +161,6 @@ internal interface ICompositeTextureArrayBackend
void Delete(CompositeTextureArrayResource resource);
}
/// <summary>
/// Narrow GL backend for composite arrays. Unlike ManagedGLTextureArray it
/// deliberately has one mip level, no PBO, and one resident handle: those are
/// the semantics of the standalone composite textures this pool replaces.
/// </summary>
internal sealed unsafe class GlCompositeTextureArrayBackend : ICompositeTextureArrayBackend
{
private readonly GL _gl;
private readonly Wb.BindlessSupport _bindless;
private readonly GlGpuDevice _device;
public GlCompositeTextureArrayBackend(GL gl, Wb.BindlessSupport bindless, GlGpuDevice device)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_bindless = bindless ?? throw new ArgumentNullException(nameof(bindless));
_device = device ?? throw new ArgumentNullException(nameof(device));
_gl.GetInteger(GetPName.MaxArrayTextureLayers, out int maximumLayers);
MaximumArrayLayers = Math.Max(1, maximumLayers);
}
public int MaximumArrayLayers { get; }
public CompositeTextureArrayResource Create(int width, int height, int capacity)
{
uint name = _gl.GenTexture();
if (name == 0)
throw new InvalidOperationException("OpenGL did not create a composite texture array.");
bool resident = false;
ulong handle = 0;
long bytes = 0;
bool tracked = false;
try
{
// Composite creation/upload runs in the render thread's pre-draw
// preparation phase. Normalize that phase to texture unit zero
// instead of synchronously reading driver binding state.
_gl.ActiveTexture(TextureUnit.Texture0);
Wb.RenderStateCache.CurrentAtlas = 0;
_gl.BindTexture(TextureTarget.Texture2DArray, name);
_gl.TexStorage3D(
TextureTarget.Texture2DArray,
levels: 1,
SizedInternalFormat.Rgba8,
checked((uint)width),
checked((uint)height),
checked((uint)capacity));
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureBaseLevel, 0);
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMaxLevel, 0);
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
handle = _bindless.GetResidentHandle(name);
resident = true;
Wb.GLHelpers.ThrowOnResourceError(
_gl,
$"creating composite texture array {width}x{height}x{capacity}");
bytes = checked((long)width * height * 4L * capacity);
Wb.GpuMemoryTracker.TrackResourceAllocation(Wb.GpuResourceType.Texture);
Wb.GpuMemoryTracker.TrackAllocation(bytes, Wb.GpuResourceType.Texture);
tracked = true;
// Campaign V slice V4t: intern the resident handle into the device's
// one texture table. A table-exhaustion throw here is caught by the
// same rollback below, and nothing was added to the table if it did.
GpuTextureSlot slot = _device.RegisterWorldTextureHandle(handle);
return new CompositeTextureArrayResource
{
Name = name,
Handle = handle,
Slot = slot,
Width = width,
Height = height,
Capacity = capacity,
Bytes = bytes,
};
}
catch (Exception creationFailure)
{
List<Exception>? cleanupFailures = null;
void Attempt(Action cleanup)
{
try { cleanup(); }
catch (Exception ex) { (cleanupFailures ??= []).Add(ex); }
}
bool residencyReleased = !resident;
if (resident)
{
Attempt(() =>
{
_bindless.MakeNonResident(handle);
Wb.GLHelpers.ThrowOnResourceError(_gl, "rolling back composite texture residency");
residencyReleased = true;
});
}
if (residencyReleased)
{
Attempt(() =>
{
_gl.DeleteTexture(name);
Wb.GLHelpers.ThrowOnResourceError(_gl, "rolling back composite texture array");
if (tracked)
{
Wb.GpuMemoryTracker.TrackDeallocation(bytes, Wb.GpuResourceType.Texture);
Wb.GpuMemoryTracker.TrackResourceDeallocation(Wb.GpuResourceType.Texture);
}
});
}
if (cleanupFailures is not null)
{
cleanupFailures.Insert(0, creationFailure);
throw new AggregateException(
"Composite texture-array construction and rollback both failed.",
cleanupFailures);
}
throw;
}
finally
{
_gl.BindTexture(TextureTarget.Texture2DArray, 0);
_gl.ActiveTexture(TextureUnit.Texture0);
}
}
public void Upload(CompositeTextureArrayResource resource, int layer, byte[] rgba)
{
_gl.ActiveTexture(TextureUnit.Texture0);
Wb.RenderStateCache.CurrentAtlas = 0;
try
{
_gl.BindBuffer(BufferTargetARB.PixelUnpackBuffer, 0);
_gl.BindTexture(TextureTarget.Texture2DArray, resource.Name);
fixed (byte* pixels = rgba)
{
_gl.TexSubImage3D(
TextureTarget.Texture2DArray,
level: 0,
xoffset: 0,
yoffset: 0,
zoffset: layer,
checked((uint)resource.Width),
checked((uint)resource.Height),
depth: 1,
PixelFormat.Rgba,
PixelType.UnsignedByte,
pixels);
}
Wb.GLHelpers.ThrowOnResourceError(
_gl,
$"uploading composite texture layer {layer} ({resource.Width}x{resource.Height})");
}
finally
{
_gl.BindTexture(TextureTarget.Texture2DArray, 0);
_gl.BindBuffer(BufferTargetARB.PixelUnpackBuffer, 0);
_gl.ActiveTexture(TextureUnit.Texture0);
}
}
public void MakeNonResident(CompositeTextureArrayResource resource)
{
Wb.GLHelpers.ThrowOnResourceError(
_gl,
$"releasing composite texture handle {resource.Handle} (precondition)");
// Campaign V slice V4t: retire the table entry before the handle it
// names stops being resident. Idempotent, so this stays correct when
// the retryable release ledger re-runs the operation.
_device.ReleaseWorldTextureHandle(resource.Handle);
_bindless.MakeNonResident(resource.Handle);
Wb.GLHelpers.ThrowOnResourceError(_gl, $"releasing composite texture handle {resource.Handle}");
}
public void Delete(CompositeTextureArrayResource resource)
{
Wb.GLHelpers.ThrowOnResourceError(
_gl,
$"deleting composite texture array {resource.Name} (precondition)");
_gl.DeleteTexture(resource.Name);
Wb.GLHelpers.ThrowOnResourceError(_gl, $"deleting composite texture array {resource.Name}");
Wb.GpuMemoryTracker.TrackDeallocation(resource.Bytes, Wb.GpuResourceType.Texture);
Wb.GpuMemoryTracker.TrackResourceDeallocation(Wb.GpuResourceType.Texture);
}
}
/// <summary>
/// Campaign V slice V6i-2: the backend-neutral composite array backend.
///
@ -525,25 +337,6 @@ internal sealed class CompositeTextureArrayCache : IDisposable
Accounted,
}
public CompositeTextureArrayCache(
GL gl,
Wb.BindlessSupport bindless,
GlGpuDevice device,
IGpuResourceRetirementQueue retirementQueue,
long unownedBudgetBytes = DefaultUnownedBudgetBytes,
long physicalBudgetBytes = DefaultPhysicalBudgetBytes,
int maximumUploadsPerFrame = DefaultMaximumUploadsPerFrame,
long maximumUploadBytesPerFrame = DefaultMaximumUploadBytesPerFrame)
: this(
new GlCompositeTextureArrayBackend(gl, bindless, device),
retirementQueue,
unownedBudgetBytes,
physicalBudgetBytes,
maximumUploadsPerFrame,
maximumUploadBytesPerFrame)
{
}
internal CompositeTextureArrayCache(
ICompositeTextureArrayBackend backend,
IGpuResourceRetirementQueue retirementQueue,

View file

@ -1,13 +1,11 @@
using System.Numerics;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Gl;
using AcDream.App.Rendering.Wb;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.App.World;
using AcDream.Core.Lighting;
using AcDream.Core.World;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
@ -377,8 +375,7 @@ internal sealed class CreatureAppraisalViewportRenderer :
private readonly PrivateEntityViewportRenderer _renderer;
public CreatureAppraisalViewportRenderer(
GL? gl,
IWorldPassScope? scope,
IWorldPassScope scope,
AcDream.App.Rendering.Gpu.IGpuDevice device,
ICurrentGpuFrameSource frames,
WbDrawDispatcher dispatcher,
@ -387,7 +384,6 @@ internal sealed class CreatureAppraisalViewportRenderer :
IWbMeshAdapter meshAdapter)
{
_renderer = new PrivateEntityViewportRenderer(
gl,
scope,
device,
frames,

View file

@ -1,32 +0,0 @@
using AcDream.App.Diagnostics;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Production render-transaction measurement adapter. CPU frame boundaries
/// remain owned by <see cref="FrameProfiler"/> while this adapter places the
/// GPU query around only the GL-producing render phases.
/// </summary>
internal sealed class FrameProfilerGpuMeasurement : IRenderFrameGpuMeasurement
{
private readonly FrameProfiler _profiler;
private readonly GL _gl;
public FrameProfilerGpuMeasurement(FrameProfiler profiler, GL gl)
{
_profiler = profiler ?? throw new ArgumentNullException(nameof(profiler));
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
}
public void BeginFrame()
{
_profiler.FrameBoundary(_gl);
_profiler.BeginGpuFrame();
}
public void EndFrame()
{
_profiler.EndGpuFrame();
}
}

View file

@ -12,15 +12,9 @@ internal interface IGameRenderResourceLifetime
internal sealed class GameRenderResourceLifetime : IGameRenderResourceLifetime
{
private readonly OwnedResourceSlot<TerrainAtlas> _terrainAtlas = new();
private readonly OwnedResourceSlot<Shader> _skyShader = new();
public TerrainAtlas AcquireTerrainAtlas(Func<TerrainAtlas> factory) =>
_terrainAtlas.Acquire(factory);
public Shader AcquireSkyShader(Func<Shader> factory) =>
_skyShader.Acquire(factory);
public void ReleaseTerrainAtlas() => _terrainAtlas.Release();
public void ReleaseSkyShader() => _skyShader.Release();
}

View file

@ -16,7 +16,6 @@ using AcDream.Runtime.Session;
using DatReaderWriter;
using Silk.NET.Input;
using Silk.NET.Maths;
using Silk.NET.OpenGL;
using Silk.NET.Windowing;
namespace AcDream.App.Rendering;
@ -56,15 +55,11 @@ public sealed class GameWindow :
AcDream.UI.Abstractions.Settings.QualityPreset.High);
private IInputContext? _input;
private TerrainModernRenderer? _terrain;
/// <summary>Phase N.5b: terrain_modern.vert/.frag program. Owned by
/// <see cref="_terrain"/> at draw time but allocated + disposed here.</summary>
private Shader? _terrainModernShader;
private CameraController? _cameraController;
private IDatReaderWriter? _dats;
private IPreparedAssetSource? _preparedAssets;
private readonly AcDream.App.Input.PointerPositionState _pointerPosition = new();
private AcDream.App.Input.CameraPointerInputController? _cameraPointerInput;
private Shader? _meshShader;
private TextureCache? _textureCache;
/// <summary>Phase N.4+: WB-backed rendering pipeline adapter. Always non-null
/// after <c>OnLoad</c> completes (modern path is mandatory as of N.5).</summary>
@ -77,11 +72,6 @@ public sealed class GameWindow :
private AcDream.App.Rendering.Selection.RetailSelectionScene? _retailSelectionScene;
private AcDream.App.Interaction.WorldSelectionQuery? _worldSelectionQuery;
private AcDream.App.Interaction.SelectionInteractionController? _selectionInteractions;
/// <summary>Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters
/// support. Required at startup — missing bindless throws
/// <see cref="NotSupportedException"/> in <c>OnLoad</c>.</summary>
private AcDream.App.Rendering.Wb.BindlessSupport? _bindlessSupport;
private SamplerCache? _samplerCache;
private DebugLineRenderer? _debugLines;
// K-fix4 (2026-04-26): default OFF. The orange BSP / green cylinder
// wireframes are noisy outdoors and confuse first-time users into
@ -128,8 +118,8 @@ public sealed class GameWindow :
private readonly AcDream.App.Rendering.GameFrameGraphSlot _frameGraphs = new();
private readonly AcDream.App.Rendering.GameRenderResourceLifetime
_renderResourceLifetime = new();
private readonly AcDream.App.Rendering.GlConstructionCleanupLedger
_glConstructionCleanup = new();
private readonly AcDream.App.Rendering.ResourceConstructionCleanupLedger
_constructionCleanup = new();
private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment;
private readonly GameWindowLifetime _lifetime = new();
private readonly DisplayFramePacingController _displayFramePacing;
@ -518,7 +508,6 @@ public sealed class GameWindow :
private readonly AcDream.UI.Abstractions.Input.KeyBindings _keyBindings;
private readonly GraphicalHostPlatformServices _platformServices;
private readonly ApplicationPathSet _applicationPaths;
private GraphicalCapabilityRecord? _graphicalCapabilities;
private static AcDream.UI.Abstractions.Input.KeyBindings LoadStartupKeyBindings(
string path)
@ -687,8 +676,7 @@ public sealed class GameWindow :
// attribute, so it must come from this same snapshot rather than a
// second settings load during OnLoad.
RuntimeSettingsSnapshot startup = _runtimeSettings.Startup;
if (_options.RenderBackend == RenderBackendKind.Vulkan
&& _options.VulkanCapabilityProbe)
if (_options.VulkanCapabilityProbe)
{
// Campaign V slice V6h reduced the V5 bring-up host to what its name
// says: a capability probe. It opens its own window, runs the gate,
@ -706,39 +694,17 @@ public sealed class GameWindow :
FramePacingPolicy startupPacing =
_displayFramePacing.InitializeStartup(startup.Display.VSync);
// Campaign V slice V6h: the ONLY startup difference between the two
// backends. Vulkan needs a client-API-less window (the surface comes from
// VK_KHR_surface), and neither MSAA nor the stencil bit count is a window
// Vulkan needs a client-API-less window (the surface comes from
// VK_KHR_surface); neither MSAA nor the stencil bit count is a window
// attribute there — both are attachment properties the RHI device
// configures, so they are passed to VulkanGraphicsContext instead.
var options = _options.RenderBackend == RenderBackendKind.Vulkan
? WindowOptions.DefaultVulkan with
{
Size = new Vector2D<int>(1280, 720),
Title = "acdream — Vulkan",
VSync = startupPacing.UseVSync,
}
: WindowOptions.Default with
{
Size = new Vector2D<int>(1280, 720),
Title = "acdream — phase 1",
API = new GraphicsAPI(
ContextAPI.OpenGL,
ContextProfile.Core,
ContextFlags.ForwardCompatible,
new APIVersion(4, 3)),
VSync = startupPacing.UseVSync,
// A.5 T22.5: MSAA from quality preset (0 = disabled, 2/4/8 = multisample).
// Silk.NET passes this to SDL_GL_SetAttribute(SDL_GL_MULTISAMPLESAMPLES).
// Cannot be changed at runtime; Quality changes mid-session that would
// alter MsaaSamples are logged as a restart-required warning.
Samples = startup.Quality.MsaaSamples,
// #117 (2026-06-11): the aperture punch's depth gate needs a
// stencil buffer (PortalDepthMaskRenderer two-pass mark+punch).
// GLFW defaults to 8 stencil bits, but make the requirement
// explicit rather than platform-implicit.
PreferredStencilBufferBits = 8,
};
// configures instead. The raw-GL window options this used to fork to
// were deleted at Campaign V slice V11.
var options = WindowOptions.DefaultVulkan with
{
Size = new Vector2D<int>(1280, 720),
Title = "acdream — Vulkan",
VSync = startupPacing.UseVSync,
};
_startupPacing = startupPacing;
_startupQuality = startup.Quality;
@ -766,7 +732,7 @@ public sealed class GameWindow :
}
catch (Exception failure)
{
_glConstructionCleanup.RetainFrom(failure);
_constructionCleanup.RetainFrom(failure);
throw;
}
}
@ -912,19 +878,6 @@ public sealed class GameWindow :
_audioSink = value.HookSink;
}
void IGameWindowWorldRenderPublication.PublishBindlessSupport(
BindlessSupport value) =>
PublishCompositionOwner(
ref _bindlessSupport,
value,
"bindless support");
void IGameWindowWorldRenderPublication.PublishTerrainShader(Shader value) =>
PublishCompositionOwner(
ref _terrainModernShader,
value,
"terrain shader");
void IGameWindowWorldRenderPublication.PublishSceneLighting(
SceneLightingUboBinding value) =>
PublishCompositionOwner(
@ -975,9 +928,6 @@ public sealed class GameWindow :
_surfaceCache = surfaceCache;
}
void IGameWindowWorldRenderPublication.PublishMeshShader(Shader value) =>
PublishCompositionOwner(ref _meshShader, value, "mesh shader");
void IGameWindowWorldRenderPublication.PublishWbMeshAdapter(
WbMeshAdapter value) =>
PublishCompositionOwner(ref _wbMeshAdapter, value, "WB mesh adapter");
@ -986,10 +936,6 @@ public sealed class GameWindow :
TextureCache value) =>
PublishCompositionOwner(ref _textureCache, value, "texture cache");
void IGameWindowWorldRenderPublication.PublishSamplerCache(
SamplerCache value) =>
PublishCompositionOwner(ref _samplerCache, value, "sampler cache");
void IGameWindowInteractionRetainedUiPublication.PublishInteractionRetainedUi(
InteractionRetainedUiResult result)
{
@ -1192,41 +1138,31 @@ public sealed class GameWindow :
}
/// <summary>
/// Campaign V slice V6h: how the UI probe reads a completed frame.
///
/// <para><see cref="FrameScreenshotController"/> applies the bottom-up flip
/// <c>glReadPixels</c> needs, so GL hands it the raw read and Vulkan — whose
/// <see cref="IGpuDevice.CaptureBackbuffer"/> is documented top-left-origin —
/// pre-flips so the two cancel. Routing GL through the same
/// <c>CaptureBackbuffer</c> seam would double-flip, which is exactly the kind
/// of "usually right" instrument §5.5 of the campaign plan spent three
/// slices removing.</para>
/// How the UI probe reads a completed frame.
/// <see cref="IGpuDevice.CaptureBackbuffer"/> is documented top-left-origin,
/// so <see cref="FrameScreenshotController"/> flips it to match the PNG's
/// row order. The raw-GL read this used to fork from — whose rows already
/// come out bottom-up, needing no flip — was deleted at Campaign V slice
/// V11.
/// </summary>
private static Func<int, int, byte[]> CreateBackbufferReader(
GameWindowGraphics graphics,
IGpuDevice device) =>
graphics.Gl is { } gl
? (width, height) =>
AcDream.App.Diagnostics.FrameScreenshotController
.ReadDefaultFramebuffer(gl, width, height)
: (width, height) =>
AcDream.App.Diagnostics.FrameScreenshotController.FlipRows(
device.CaptureBackbuffer(width, height),
width,
height);
(width, height) =>
AcDream.App.Diagnostics.FrameScreenshotController.FlipRows(
device.CaptureBackbuffer(width, height),
width,
height);
/// <summary>
/// Campaign V slice V6h: the backend fork, in one expression. Vulkan's
/// context acquisition runs its own capability gate inside
/// Vulkan's context acquisition runs its own capability gate inside
/// <see cref="AcDream.App.Rendering.Gpu.Vk.VulkanGraphicsContext.Acquire"/>,
/// throwing <see cref="NotSupportedException"/> into the same exit-code-4
/// contract <c>Program.cs</c> publishes for GL.
/// contract <c>Program.cs</c> publishes. The raw-GL fork this used to make
/// was deleted at Campaign V slice V11.
/// </summary>
private GameWindowGraphics CreateGraphics()
{
if (_options.RenderBackend != RenderBackendKind.Vulkan)
return new OpenGlGameWindowGraphics(GL.GetApi(_window!));
AcDream.App.Rendering.Gpu.Vk.VulkanGraphicsContext vulkan =
AcDream.App.Rendering.Gpu.Vk.VulkanGraphicsContext.Acquire(
_window!,
@ -1245,32 +1181,10 @@ public sealed class GameWindow :
// run narrow-phase BSP tests during FindObjCollisions.
GameWindowPlatformResult<GameWindowGraphics, IInputContext> platform = AcquirePlatform();
// The GL capability gate reads GL extension strings, so it runs only on
// the GL arm. Vulkan's equivalent gate already ran inside
// VulkanGraphicsContext.Acquire and wrote its own report.
if (platform.Graphics.Gl is { } capabilityGl)
{
string capabilityReportPath = Path.Combine(
_applicationPaths.DiagnosticsDirectory,
"graphical-capabilities.json");
_graphicalCapabilities =
GraphicalCapabilityGuard.CaptureVerifyAndWrite(
capabilityGl,
_window!,
platform.Input,
_platformServices,
capabilityReportPath);
GraphicalCapabilityGuard.ThrowIfUnsupported(
_graphicalCapabilities,
capabilityReportPath);
Console.WriteLine(
"graphics: capability gate passed " +
$"({_graphicalCapabilities.ActiveDisplayProtocol}, " +
$"{_graphicalCapabilities.GlVendor}, " +
$"{_graphicalCapabilities.GlRenderer}, " +
$"{_graphicalCapabilities.GlVersion}); " +
$"report={capabilityReportPath}");
}
// The raw-GL capability gate that used to run here (reading GL
// extension strings) was deleted at Campaign V slice V11. Vulkan's
// equivalent gate already ran inside VulkanGraphicsContext.Acquire and
// wrote its own report.
GameWindowCompositionPipeline.Run<
GameWindowPlatformResult<GameWindowGraphics, IInputContext>,
@ -1727,12 +1641,9 @@ public sealed class GameWindow :
_clipFrame,
_skyRenderer,
_particleRenderer,
_samplerCache,
_textureCache,
_wbMeshAdapter,
_meshShader,
_terrain,
_terrainModernShader,
_sceneLightingUbo,
_debugLines,
_textRenderer,
@ -1740,7 +1651,7 @@ public sealed class GameWindow :
_displayFramePacing,
_frameProfiler,
_renderResourceLifetime,
_glConstructionCleanup),
_constructionCleanup),
new PlatformShutdownRoots(
_dats,
_preparedAssets,

View file

@ -25,7 +25,6 @@ using AcDream.Runtime.Session;
using AcDream.UI.Abstractions.Input;
using DatReaderWriter;
using Silk.NET.Input;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
@ -111,12 +110,9 @@ internal sealed record RenderShutdownRoots(
ClipFrame? ClipFrame,
SkyRenderer? Sky,
ParticleRenderer? Particles,
SamplerCache? Samplers,
TextureCache? Textures,
WbMeshAdapter? MeshAdapter,
Shader? MeshShader,
TerrainModernRenderer? Terrain,
Shader? TerrainShader,
SceneLightingUboBinding? SceneLighting,
DebugLineRenderer? DebugLines,
TextRenderer? TextRenderer,
@ -124,7 +120,7 @@ internal sealed record RenderShutdownRoots(
DisplayFramePacingController FramePacing,
FrameProfiler FrameProfiler,
GameRenderResourceLifetime DedicatedResources,
GlConstructionCleanupLedger ConstructionCleanup);
ResourceConstructionCleanupLedger ConstructionCleanup);
internal sealed record PlatformShutdownRoots(
IDatReaderWriter? Dats,
@ -439,7 +435,6 @@ internal static class GameWindowShutdownManifest
]),
new ResourceShutdownStage("shared texture owners",
[
Hard("sampler cache", () => render.Samplers?.Dispose()),
Hard("texture cache", () => render.Textures?.Dispose()),
]),
new ResourceShutdownStage("mesh adapter",
@ -448,9 +443,7 @@ internal static class GameWindowShutdownManifest
]),
new ResourceShutdownStage("remaining render owners",
[
Hard("mesh shader", () => render.MeshShader?.Dispose()),
Hard("terrain", () => render.Terrain?.Dispose()),
Hard("terrain shader", () => render.TerrainShader?.Dispose()),
Hard("scene lighting", () => render.SceneLighting?.Dispose()),
Hard("debug lines", () => render.DebugLines?.Dispose()),
Hard("text renderer", () => render.TextRenderer?.Dispose()),
@ -461,12 +454,11 @@ internal static class GameWindowShutdownManifest
]),
new ResourceShutdownStage("dedicated render resources",
[
Hard("sky shader", render.DedicatedResources.ReleaseSkyShader),
Hard("terrain atlas", render.DedicatedResources.ReleaseTerrainAtlas),
]),
new ResourceShutdownStage("failed render construction cleanup",
[
Hard("GL construction ledger", render.ConstructionCleanup.Dispose),
Hard("resource construction ledger", render.ConstructionCleanup.Dispose),
]),
new ResourceShutdownStage("frame flight owner",
[
@ -481,7 +473,7 @@ internal static class GameWindowShutdownManifest
[
Hard("input context", () => platform.Input?.Dispose()),
]),
new ResourceShutdownStage("OpenGL context",
new ResourceShutdownStage("graphics API context",
[
Hard("graphics API", () => platform.Graphics?.Dispose()),
]));

View file

@ -1,102 +0,0 @@
namespace AcDream.App.Rendering;
internal interface IRetryableResourceCleanup
{
bool IsCleanupComplete { get; }
void RetryCleanup();
}
internal sealed class GlResourceConstructionException : AggregateException,
IRetryableResourceCleanup
{
private readonly IRetryableResourceCleanup _cleanup;
public GlResourceConstructionException(
string message,
IRetryableResourceCleanup cleanup,
IEnumerable<Exception> failures)
: base(message, failures)
{
_cleanup = cleanup ?? throw new ArgumentNullException(nameof(cleanup));
}
public bool IsCleanupComplete => _cleanup.IsCleanupComplete;
public void RetryCleanup() => _cleanup.RetryCleanup();
}
/// <summary>
/// Lifetime root for cleanup work that could not finish before a throwing GL
/// factory returned control. The original exception remains the retry owner;
/// this ledger prevents it and its exact pending names from becoming local-only.
/// </summary>
internal sealed class GlConstructionCleanupLedger : IDisposable
{
private readonly List<IRetryableResourceCleanup> _pending = [];
private bool _disposing;
public bool IsComplete => _pending.Count == 0;
public bool RetainFrom(Exception failure)
{
ArgumentNullException.ThrowIfNull(failure);
bool retained = false;
Visit(failure);
return retained;
void Visit(Exception current)
{
if (current is IRetryableResourceCleanup cleanup)
{
if (!cleanup.IsCleanupComplete && !_pending.Contains(cleanup))
_pending.Add(cleanup);
retained = true;
}
if (current is AggregateException aggregate)
{
foreach (Exception inner in aggregate.InnerExceptions)
Visit(inner);
}
else if (current.InnerException is { } inner)
{
Visit(inner);
}
}
}
public void Dispose()
{
if (_disposing || _pending.Count == 0)
return;
_disposing = true;
List<Exception>? failures = null;
try
{
for (int i = _pending.Count - 1; i >= 0; i--)
{
IRetryableResourceCleanup cleanup = _pending[i];
try
{
cleanup.RetryCleanup();
if (cleanup.IsCleanupComplete)
_pending.RemoveAt(i);
}
catch (Exception failure)
{
(failures ??= []).Add(failure);
}
}
}
finally
{
_disposing = false;
}
if (failures is not null)
throw new AggregateException(
"One or more failed GL construction transactions remain pending.",
failures);
}
}

View file

@ -1,152 +0,0 @@
using System.Runtime.ExceptionServices;
using AcDream.App.Rendering.Wb;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Always-on commit boundary for GL resource commands. OpenGL reports ordinary
/// command failures through its error flag, so an ownership state machine may
/// advance only after the post-command check succeeds.
/// </summary>
internal static class GlResourceCommand
{
public static void Execute(GL gl, string context, Action command)
{
ArgumentNullException.ThrowIfNull(gl);
ArgumentException.ThrowIfNullOrWhiteSpace(context);
ArgumentNullException.ThrowIfNull(command);
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
command();
GLHelpers.ThrowOnResourceError(gl, context);
}
public static T Execute<T>(GL gl, string context, Func<T> command)
{
ArgumentNullException.ThrowIfNull(gl);
ArgumentException.ThrowIfNullOrWhiteSpace(context);
ArgumentNullException.ThrowIfNull(command);
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
T result = command();
GLHelpers.ThrowOnResourceError(gl, context);
return result;
}
public static uint CreateName(
GL gl,
string resourceName,
Func<uint> create,
Action<uint> delete)
{
ArgumentException.ThrowIfNullOrWhiteSpace(resourceName);
ArgumentNullException.ThrowIfNull(create);
ArgumentNullException.ThrowIfNull(delete);
return CreateNameCore(
resourceName,
() => GLHelpers.ThrowOnResourceError(
gl,
$"create {resourceName} (precondition)"),
create,
() => GLHelpers.ThrowOnResourceError(gl, $"create {resourceName}"),
ownedName => Execute(
gl,
$"rollback {resourceName} name {ownedName}",
() => delete(ownedName)));
}
internal static uint CreateNameCore(
string resourceName,
Action precondition,
Func<uint> create,
Action postcondition,
Action<uint> deleteChecked)
{
ArgumentException.ThrowIfNullOrWhiteSpace(resourceName);
ArgumentNullException.ThrowIfNull(precondition);
ArgumentNullException.ThrowIfNull(create);
ArgumentNullException.ThrowIfNull(postcondition);
ArgumentNullException.ThrowIfNull(deleteChecked);
uint name = 0;
Exception? creationFailure = null;
try
{
precondition();
name = create();
if (name == 0)
throw new InvalidOperationException($"OpenGL returned no {resourceName} name.");
postcondition();
return name;
}
catch (Exception failure)
{
creationFailure = failure;
}
if (name != 0)
{
var cleanup = new SingleNameCleanup(name, deleteChecked);
try
{
cleanup.RetryCleanup();
}
catch (Exception cleanupFailure)
{
throw new GlResourceConstructionException(
$"Creating {resourceName} failed and its returned GL name could not be released.",
cleanup,
[creationFailure, cleanupFailure]);
}
}
ExceptionDispatchInfo.Capture(creationFailure).Throw();
throw new InvalidOperationException("Unreachable GL resource-creation path.");
}
public static uint CreateTexture(GL gl, string context) =>
CreateName(gl, context, gl.GenTexture, gl.DeleteTexture);
public static void DeleteTexture(GL gl, uint texture, string context) =>
Execute(gl, context, () => gl.DeleteTexture(texture));
public static void DeleteShader(GL gl, uint shader, string context) =>
Execute(gl, context, () => gl.DeleteShader(shader));
public static void DeleteProgram(GL gl, uint program, string context) =>
Execute(gl, context, () => gl.DeleteProgram(program));
public static void DeleteBuffer(GL gl, uint buffer, string context) =>
Execute(gl, context, () => gl.DeleteBuffer(buffer));
public static void DeleteVertexArray(GL gl, uint vertexArray, string context) =>
Execute(gl, context, () => gl.DeleteVertexArray(vertexArray));
private sealed class SingleNameCleanup(uint name, Action<uint> delete)
: IRetryableResourceCleanup
{
private uint _name = name;
private bool _running;
public bool IsCleanupComplete => _name == 0;
public void RetryCleanup()
{
if (_running || _name == 0)
return;
_running = true;
try
{
delete(_name);
_name = 0;
}
finally
{
_running = false;
}
}
}
}

View file

@ -1,85 +0,0 @@
namespace AcDream.App.Rendering;
internal interface IGlTextureNameApi
{
uint GenTexture();
void DeleteTexture(uint texture);
}
internal sealed class GlTextureNameApi(Silk.NET.OpenGL.GL gl) : IGlTextureNameApi
{
public uint GenTexture() =>
GlResourceCommand.CreateTexture(gl, "terrain construction texture");
public void DeleteTexture(uint texture) =>
GlResourceCommand.DeleteTexture(
gl,
texture,
$"delete terrain construction texture {texture}");
}
/// <summary>
/// Retains every texture name immediately after allocation until the complete
/// aggregate owner has been constructed. A failed factory rolls names back in
/// reverse acquisition order and attempts every deletion.
/// </summary>
internal sealed class GlTextureConstructionTransaction(IGlTextureNameApi api)
: IRetryableResourceCleanup
{
private readonly List<uint> _ownedNames = [];
private bool _finished;
public bool IsCleanupComplete => _finished;
public uint Allocate()
{
if (_finished)
throw new InvalidOperationException("The texture construction transaction is finished.");
uint texture = api.GenTexture();
if (texture == 0)
throw new InvalidOperationException("OpenGL returned no texture name.");
_ownedNames.Add(texture);
return texture;
}
public void Commit()
{
if (_finished)
throw new InvalidOperationException("The texture construction transaction is finished.");
_ownedNames.Clear();
_finished = true;
}
public void Rollback()
{
if (_finished)
return;
List<Exception>? failures = null;
for (int i = _ownedNames.Count - 1; i >= 0; i--)
{
uint texture = _ownedNames[i];
try
{
api.DeleteTexture(texture);
_ownedNames.RemoveAt(i);
}
catch (Exception failure)
{
(failures ??= []).Add(new InvalidOperationException(
$"Texture construction rollback could not delete OpenGL name {texture}.",
failure));
}
}
if (failures is not null)
throw new AggregateException(
"Texture construction rollback did not release every allocated OpenGL name.",
failures);
_finished = true;
}
public void RetryCleanup() => Rollback();
}

View file

@ -1,313 +0,0 @@
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering.Gpu.Gl;
/// <summary>
/// The narrow slice of GL that <see cref="GlAmbientCapabilityState"/> reads and
/// writes. It exists so the save/restore transaction can be exercised without a
/// GL context: the property that matters is "every value this pass can change
/// is put back, including when a draw throws", and that is a property of the
/// bookkeeping, not of the driver.
///
/// Slice V6d generalized this from <c>ITextRenderGlStateApi</c>, which
/// <c>TextRenderer</c> owned privately and which restored a strict subset of the
/// same values. That renderer no longer touches GL at all, and the guarantee now
/// lives in one place for every RHI pass.
/// </summary>
internal interface IGlAmbientStateApi
{
bool IsEnabled(EnableCap capability);
int GetInteger(GetPName parameter);
bool GetBoolean(GetPName parameter);
/// <summary>The four colour-mask channels, in RGBA order.</summary>
bool[] GetColorMask();
void SetCapability(EnableCap capability, bool enabled);
void DepthMask(bool enabled);
void DepthFunc(DepthFunction function);
void BlendFuncSeparate(
BlendingFactor sourceRgb,
BlendingFactor destinationRgb,
BlendingFactor sourceAlpha,
BlendingFactor destinationAlpha);
void CullFace(TriangleFace face);
void FrontFace(FrontFaceDirection direction);
void StencilFunc(StencilFunction function, int reference, uint mask);
void StencilOp(StencilOp fail, StencilOp depthFail, StencilOp pass);
void StencilMask(uint mask);
void ColorMask(bool red, bool green, bool blue, bool alpha);
void UseProgram(uint program);
void BindVertexArray(uint vertexArray);
void BindBuffer(BufferTargetARB target, uint buffer);
void ActiveTexture(TextureUnit unit);
void BindTexture(TextureTarget target, uint texture);
}
internal sealed class SilkGlAmbientStateApi : IGlAmbientStateApi
{
private readonly GL _gl;
public SilkGlAmbientStateApi(GL gl) => _gl = gl ?? throw new ArgumentNullException(nameof(gl));
public bool IsEnabled(EnableCap capability) => _gl.IsEnabled(capability);
public int GetInteger(GetPName parameter)
{
_gl.GetInteger(parameter, out int value);
return value;
}
public bool GetBoolean(GetPName parameter) => _gl.GetBoolean(parameter);
public unsafe bool[] GetColorMask()
{
var values = new bool[4];
fixed (bool* first = values)
_gl.GetBoolean(GetPName.ColorWritemask, first);
return values;
}
public void SetCapability(EnableCap capability, bool enabled)
{
if (enabled)
_gl.Enable(capability);
else
_gl.Disable(capability);
}
public void DepthMask(bool enabled) => _gl.DepthMask(enabled);
public void DepthFunc(DepthFunction function) => _gl.DepthFunc(function);
public void BlendFuncSeparate(
BlendingFactor sourceRgb,
BlendingFactor destinationRgb,
BlendingFactor sourceAlpha,
BlendingFactor destinationAlpha) =>
_gl.BlendFuncSeparate(sourceRgb, destinationRgb, sourceAlpha, destinationAlpha);
public void CullFace(TriangleFace face) => _gl.CullFace(face);
public void FrontFace(FrontFaceDirection direction) => _gl.FrontFace(direction);
public void StencilFunc(StencilFunction function, int reference, uint mask) =>
_gl.StencilFunc(function, reference, mask);
public void StencilOp(StencilOp fail, StencilOp depthFail, StencilOp pass) =>
_gl.StencilOp(fail, depthFail, pass);
public void StencilMask(uint mask) => _gl.StencilMask(mask);
public void ColorMask(bool red, bool green, bool blue, bool alpha) =>
_gl.ColorMask(red, green, blue, alpha);
public void UseProgram(uint program) => _gl.UseProgram(program);
public void BindVertexArray(uint vertexArray) => _gl.BindVertexArray(vertexArray);
public void BindBuffer(BufferTargetARB target, uint buffer) => _gl.BindBuffer(target, buffer);
public void ActiveTexture(TextureUnit unit) => _gl.ActiveTexture(unit);
public void BindTexture(TextureTarget target, uint texture) => _gl.BindTexture(target, texture);
}
/// <summary>
/// Every ambient GL capability/binding a <see cref="GlGpuPipeline"/> bind (or a
/// dynamic setter, or the pass's own sample count) can change, captured by raw
/// query and restored by raw call.
///
/// Transitional for as long as raw-GL renderers coexist with RHI-ported ones:
/// each raw-GL renderer assumes whatever state the previous one left behind is
/// still there, so a pass that changes state and does not put it back is
/// invisible until the world silhouette changes. That is precisely how the first
/// V4a attempt lost multisampling (plan §7.1 rule 1). Deleted at V4h.
/// </summary>
internal readonly struct GlAmbientCapabilityState
{
private readonly int _program;
private readonly int _vertexArray;
private readonly int _arrayBuffer;
private readonly int _activeTexture;
private readonly int _texture0Binding2D;
private readonly bool _depthTest;
private readonly bool _depthWrite;
private readonly int _depthFunc;
private readonly bool _blend;
private readonly int _blendSourceRgb;
private readonly int _blendDestinationRgb;
private readonly int _blendSourceAlpha;
private readonly int _blendDestinationAlpha;
private readonly bool _cullFace;
private readonly int _cullFaceMode;
private readonly int _frontFace;
private readonly bool _alphaToCoverage;
private readonly bool _multisample;
// Slice V6l: the stencil dimension. #117's portal punch is the only pipeline
// that enables it, and it draws in the middle of a frame whose other
// renderers are still raw GL and assume the test is off.
private readonly bool _stencilTest;
private readonly int _stencilFunc;
private readonly int _stencilReference;
private readonly int _stencilValueMask;
private readonly int _stencilWriteMask;
private readonly int _stencilFail;
private readonly int _stencilDepthFail;
private readonly int _stencilPass;
// Slice V6l: GpuPipelineDescription.ColorWrite has had no consumer until the
// portal depth mask, which is colour-invisible by construction. A pass that
// left the mask off would black out every raw-GL renderer that followed it,
// which is §7.1 rule 1's exact failure mode wearing a different name.
private readonly bool _colorMaskRed;
private readonly bool _colorMaskGreen;
private readonly bool _colorMaskBlue;
private readonly bool _colorMaskAlpha;
private GlAmbientCapabilityState(
int program, int vertexArray, int arrayBuffer, int activeTexture, int texture0Binding2D,
bool depthTest, bool depthWrite, int depthFunc,
bool blend, int blendSourceRgb, int blendDestinationRgb, int blendSourceAlpha, int blendDestinationAlpha,
bool cullFace, int cullFaceMode, int frontFace,
bool alphaToCoverage, bool multisample,
bool stencilTest, int stencilFunc, int stencilReference, int stencilValueMask,
int stencilWriteMask, int stencilFail, int stencilDepthFail, int stencilPass,
bool colorMaskRed, bool colorMaskGreen, bool colorMaskBlue, bool colorMaskAlpha)
{
_program = program;
_vertexArray = vertexArray;
_arrayBuffer = arrayBuffer;
_activeTexture = activeTexture;
_texture0Binding2D = texture0Binding2D;
_depthTest = depthTest;
_depthWrite = depthWrite;
_depthFunc = depthFunc;
_blend = blend;
_blendSourceRgb = blendSourceRgb;
_blendDestinationRgb = blendDestinationRgb;
_blendSourceAlpha = blendSourceAlpha;
_blendDestinationAlpha = blendDestinationAlpha;
_cullFace = cullFace;
_cullFaceMode = cullFaceMode;
_frontFace = frontFace;
_alphaToCoverage = alphaToCoverage;
_multisample = multisample;
_stencilTest = stencilTest;
_stencilFunc = stencilFunc;
_stencilReference = stencilReference;
_stencilValueMask = stencilValueMask;
_stencilWriteMask = stencilWriteMask;
_stencilFail = stencilFail;
_stencilDepthFail = stencilDepthFail;
_stencilPass = stencilPass;
_colorMaskRed = colorMaskRed;
_colorMaskGreen = colorMaskGreen;
_colorMaskBlue = colorMaskBlue;
_colorMaskAlpha = colorMaskAlpha;
}
internal static GlAmbientCapabilityState Capture(IGlAmbientStateApi gl)
{
ArgumentNullException.ThrowIfNull(gl);
int program = gl.GetInteger(GetPName.CurrentProgram);
int vertexArray = gl.GetInteger(GetPName.VertexArrayBinding);
int arrayBuffer = gl.GetInteger(GetPName.ArrayBufferBinding);
int activeTexture = gl.GetInteger(GetPName.ActiveTexture);
int texture0Binding2D;
try
{
gl.ActiveTexture(TextureUnit.Texture0);
texture0Binding2D = gl.GetInteger(GetPName.TextureBinding2D);
}
finally
{
gl.ActiveTexture((TextureUnit)activeTexture);
}
bool[] colorMask = gl.GetColorMask();
return new GlAmbientCapabilityState(
program, vertexArray, arrayBuffer, activeTexture, texture0Binding2D,
gl.IsEnabled(EnableCap.DepthTest),
gl.GetBoolean(GetPName.DepthWritemask),
gl.GetInteger(GetPName.DepthFunc),
gl.IsEnabled(EnableCap.Blend),
gl.GetInteger(GetPName.BlendSrcRgb),
gl.GetInteger(GetPName.BlendDstRgb),
gl.GetInteger(GetPName.BlendSrcAlpha),
gl.GetInteger(GetPName.BlendDstAlpha),
gl.IsEnabled(EnableCap.CullFace),
gl.GetInteger(GetPName.CullFaceMode),
gl.GetInteger(GetPName.FrontFace),
gl.IsEnabled(EnableCap.SampleAlphaToCoverage),
gl.IsEnabled(EnableCap.Multisample),
gl.IsEnabled(EnableCap.StencilTest),
gl.GetInteger(GetPName.StencilFunc),
gl.GetInteger(GetPName.StencilRef),
gl.GetInteger(GetPName.StencilValueMask),
gl.GetInteger(GetPName.StencilWritemask),
gl.GetInteger(GetPName.StencilFail),
gl.GetInteger(GetPName.StencilPassDepthFail),
gl.GetInteger(GetPName.StencilPassDepthPass),
colorMask[0], colorMask[1], colorMask[2], colorMask[3]);
}
internal void Restore(IGlAmbientStateApi gl)
{
ArgumentNullException.ThrowIfNull(gl);
gl.UseProgram((uint)_program);
gl.BindVertexArray((uint)_vertexArray);
gl.BindBuffer(BufferTargetARB.ArrayBuffer, (uint)_arrayBuffer);
gl.ActiveTexture(TextureUnit.Texture0);
gl.BindTexture(TextureTarget.Texture2D, (uint)_texture0Binding2D);
gl.ActiveTexture((TextureUnit)_activeTexture);
gl.SetCapability(EnableCap.DepthTest, _depthTest);
gl.DepthMask(_depthWrite);
gl.DepthFunc((DepthFunction)_depthFunc);
gl.SetCapability(EnableCap.Blend, _blend);
gl.BlendFuncSeparate(
(BlendingFactor)_blendSourceRgb,
(BlendingFactor)_blendDestinationRgb,
(BlendingFactor)_blendSourceAlpha,
(BlendingFactor)_blendDestinationAlpha);
gl.SetCapability(EnableCap.CullFace, _cullFace);
gl.CullFace((TriangleFace)_cullFaceMode);
gl.FrontFace((FrontFaceDirection)_frontFace);
gl.SetCapability(EnableCap.SampleAlphaToCoverage, _alphaToCoverage);
gl.SetCapability(EnableCap.Multisample, _multisample);
gl.SetCapability(EnableCap.StencilTest, _stencilTest);
gl.StencilFunc(
(StencilFunction)_stencilFunc,
_stencilReference,
(uint)_stencilValueMask);
gl.StencilOp(
(StencilOp)_stencilFail,
(StencilOp)_stencilDepthFail,
(StencilOp)_stencilPass);
gl.StencilMask((uint)_stencilWriteMask);
gl.ColorMask(_colorMaskRed, _colorMaskGreen, _colorMaskBlue, _colorMaskAlpha);
}
}

View file

@ -1,108 +0,0 @@
namespace AcDream.App.Rendering.Gpu.Gl;
/// <summary>
/// Tracks which texture-table slots changed since the last flush and gives them
/// back as maximal runs of <i>consecutive</i> dirty slots.
///
/// <para><b>Why runs, and not one merged range.</b> The ring can flush a single
/// span covering everything written since the last draw, because every byte in
/// that span was allocated this frame and nothing in flight reads it. The
/// texture table cannot: it is a long-lived array of bindless handles, and two
/// registrations in one frame can land on slots 5 and 50 with forty-four live
/// slots in between. Those live slots are read by draws already submitted this
/// frame, so a single mapped write over [5, 51) — mapped with
/// <c>GL_MAP_INVALIDATE_RANGE_BIT</c>, which lets the driver discard the whole
/// range's contents while it is mapped, and <c>GL_MAP_UNSYNCHRONIZED_BIT</c>,
/// which stops it from waiting first — would expose those draws to torn
/// handles. Splitting on the gaps keeps every mapped range made only of slots
/// that no submitted draw can be reading.</para>
///
/// <para><b>Why a dirty slot is safe to write unsynchronized.</b> Two producers
/// touch a slot: <c>GlGpuDevice.RegisterTexture</c>, which writes a slot fresh
/// from <see cref="GlTextureSlotAllocator"/> and therefore one no batch has ever
/// indexed; and <c>ReleaseTextureSlot</c>'s zeroing write, which runs inside a
/// retirement callback, i.e. after the fence covering every frame that could
/// still have referenced it. Both are already past the point where the GPU can
/// read the old value — which is exactly the assertion
/// <c>GL_MAP_UNSYNCHRONIZED_BIT</c> makes.</para>
///
/// <para>GL-free by design, like <see cref="GlRingBufferState"/> and
/// <see cref="GlTextureSlotAllocator"/>, so the bookkeeping is unit tested
/// without a context. Enumeration is allocation-free: the tracker keeps the
/// min/max window that has been dirtied so a clean table costs one comparison,
/// and a dirty one scans only that window.</para>
/// </summary>
internal sealed class GlDirtySlotRuns
{
private readonly bool[] _dirty;
private int _windowStart = -1;
private int _windowEnd = -1;
public GlDirtySlotRuns(uint capacity)
{
ArgumentOutOfRangeException.ThrowIfZero(capacity);
_dirty = new bool[capacity];
}
public uint Capacity => (uint)_dirty.Length;
public bool HasDirtySlots => _windowStart >= 0;
public void Mark(uint slot)
{
if (slot >= (uint)_dirty.Length)
{
throw new ArgumentOutOfRangeException(
nameof(slot),
slot,
$"The tracked table holds {_dirty.Length} slots.");
}
_dirty[slot] = true;
_windowStart = _windowStart < 0 ? (int)slot : Math.Min(_windowStart, (int)slot);
_windowEnd = Math.Max(_windowEnd, (int)slot + 1);
}
/// <summary>
/// Takes the lowest remaining run of consecutive dirty slots, clearing it,
/// and reports its first slot and length. Returns <c>false</c> once none
/// remain, so a caller drains with a <c>while</c> loop.
/// </summary>
public bool TryTakeNextRun(out uint firstSlot, out uint slotCount)
{
firstSlot = 0;
slotCount = 0;
if (_windowStart < 0)
return false;
int index = _windowStart;
while (index < _windowEnd && !_dirty[index])
index++;
if (index >= _windowEnd)
{
CloseWindow();
return false;
}
int start = index;
while (index < _windowEnd && _dirty[index])
{
_dirty[index] = false;
index++;
}
firstSlot = (uint)start;
slotCount = (uint)(index - start);
if (index >= _windowEnd)
CloseWindow();
else
_windowStart = index;
return true;
}
private void CloseWindow()
{
_windowStart = -1;
_windowEnd = -1;
}
}

View file

@ -1,145 +0,0 @@
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering.Gpu.Gl;
/// <summary>One vertex attribute's GL shape: component count, element type, and whether integer values normalize to [0,1]/[-1,1].</summary>
/// <summary>
/// How one vertex attribute reaches GL. <paramref name="Integer"/> selects
/// <c>glVertexAttribIPointer</c> over <c>glVertexAttribPointer</c>: GL requires
/// the integer entry point for an integer shader input (<c>uvec4</c> and friends)
/// and leaves the value undefined otherwise.
/// </summary>
internal readonly record struct GlVertexAttributeShape(
int ComponentCount,
VertexAttribPointerType Type,
bool Normalized,
bool Integer = false);
/// <summary>
/// Pure, GL-context-free mappings from the RHI's backend-neutral enums to
/// Silk.NET's OpenGL enum values. Kept as small switch expressions so each
/// mapping is independently unit-testable and — per the campaign spec's
/// "when in doubt, set the state" rule — every backend-neutral value has an
/// explicit case rather than a fallthrough default.
/// </summary>
internal static class GlEnumMapping
{
public static GlVertexAttributeShape VertexShapeOf(GpuVertexFormat format) => format switch
{
GpuVertexFormat.Float1 => new GlVertexAttributeShape(1, VertexAttribPointerType.Float, false),
GpuVertexFormat.Float2 => new GlVertexAttributeShape(2, VertexAttribPointerType.Float, false),
GpuVertexFormat.Float3 => new GlVertexAttributeShape(3, VertexAttribPointerType.Float, false),
GpuVertexFormat.Float4 => new GlVertexAttributeShape(4, VertexAttribPointerType.Float, false),
GpuVertexFormat.UByte4Normalized => new GlVertexAttributeShape(4, VertexAttribPointerType.UnsignedByte, true),
// Integer attributes carry Integer = true; the encoder must route them
// through glVertexAttribIPointer, not the normalized float path.
GpuVertexFormat.UByte4UInt => new GlVertexAttributeShape(4, VertexAttribPointerType.UnsignedByte, false, Integer: true),
// Slice V6l: particle.vert's per-instance `in uint aTextureIndex`.
GpuVertexFormat.UInt1 => new GlVertexAttributeShape(1, VertexAttribPointerType.UnsignedInt, false, Integer: true),
_ => throw new NotSupportedException($"No GL vertex attribute shape for {format}."),
};
/// <summary>Slice V6l: the stencil comparison, which shares GL's depth-function enum values.</summary>
public static StencilFunction StencilFunctionOf(GpuCompareOp compare) => compare switch
{
GpuCompareOp.Never => StencilFunction.Never,
GpuCompareOp.Less => StencilFunction.Less,
GpuCompareOp.LessOrEqual => StencilFunction.Lequal,
GpuCompareOp.Equal => StencilFunction.Equal,
GpuCompareOp.Greater => StencilFunction.Greater,
GpuCompareOp.GreaterOrEqual => StencilFunction.Gequal,
GpuCompareOp.Always => StencilFunction.Always,
_ => throw new NotSupportedException($"No GL stencil function for {compare}."),
};
/// <summary>Slice V6l: what a stencil outcome does to the stored value.</summary>
public static StencilOp StencilOpOf(GpuStencilOp op) => op switch
{
GpuStencilOp.Keep => StencilOp.Keep,
GpuStencilOp.Zero => StencilOp.Zero,
GpuStencilOp.Replace => StencilOp.Replace,
_ => throw new NotSupportedException($"No GL stencil operation for {op}."),
};
public static PrimitiveType PrimitiveTypeOf(GpuPrimitiveTopology topology) => topology switch
{
GpuPrimitiveTopology.TriangleList => PrimitiveType.Triangles,
GpuPrimitiveTopology.LineList => PrimitiveType.Lines,
_ => throw new NotSupportedException($"No GL primitive type for {topology}."),
};
public static DrawElementsType DrawElementsTypeOf(GpuIndexType indexType) => indexType switch
{
GpuIndexType.UInt16 => DrawElementsType.UnsignedShort,
GpuIndexType.UInt32 => DrawElementsType.UnsignedInt,
_ => throw new NotSupportedException($"No GL index type for {indexType}."),
};
public static int IndexSizeBytesOf(GpuIndexType indexType) => indexType switch
{
GpuIndexType.UInt16 => sizeof(ushort),
GpuIndexType.UInt32 => sizeof(uint),
_ => throw new NotSupportedException($"No index byte size for {indexType}."),
};
public static DepthFunction DepthFunctionOf(GpuCompareOp compareOp) => compareOp switch
{
GpuCompareOp.Never => DepthFunction.Never,
GpuCompareOp.Less => DepthFunction.Less,
GpuCompareOp.LessOrEqual => DepthFunction.Lequal,
GpuCompareOp.Equal => DepthFunction.Equal,
GpuCompareOp.Greater => DepthFunction.Greater,
GpuCompareOp.GreaterOrEqual => DepthFunction.Gequal,
GpuCompareOp.Always => DepthFunction.Always,
_ => throw new NotSupportedException($"No GL depth function for {compareOp}."),
};
public static TriangleFace CullFaceModeOf(GpuCullMode cullMode) => cullMode switch
{
GpuCullMode.Back => TriangleFace.Back,
GpuCullMode.Front => TriangleFace.Front,
// GpuCullMode.None never reaches glCullFace — culling is disabled instead.
_ => throw new NotSupportedException($"No GL cull face mode for {cullMode}."),
};
public static FrontFaceDirection FrontFaceDirectionOf(GpuFrontFace frontFace) => frontFace switch
{
GpuFrontFace.CounterClockwise => FrontFaceDirection.Ccw,
GpuFrontFace.Clockwise => FrontFaceDirection.CW,
_ => throw new NotSupportedException($"No GL front-face direction for {frontFace}."),
};
public static (BlendingFactor Source, BlendingFactor Destination) BlendFactorsOf(GpuBlendMode blend) => blend switch
{
GpuBlendMode.StraightAlpha => (BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha),
GpuBlendMode.Additive => (BlendingFactor.SrcAlpha, BlendingFactor.One),
GpuBlendMode.InverseAlpha => (BlendingFactor.OneMinusSrcAlpha, BlendingFactor.SrcAlpha),
// GpuBlendMode.None never reaches glBlendFunc — blending is disabled instead.
_ => throw new NotSupportedException($"No GL blend factors for {blend}."),
};
public static TextureMinFilter MinFilterOf(GpuFilter filter, GpuMipFilter mipFilter) => (filter, mipFilter) switch
{
(GpuFilter.Nearest, GpuMipFilter.None) => TextureMinFilter.Nearest,
(GpuFilter.Linear, GpuMipFilter.None) => TextureMinFilter.Linear,
(GpuFilter.Nearest, GpuMipFilter.Nearest) => TextureMinFilter.NearestMipmapNearest,
(GpuFilter.Linear, GpuMipFilter.Nearest) => TextureMinFilter.LinearMipmapNearest,
(GpuFilter.Nearest, GpuMipFilter.Linear) => TextureMinFilter.NearestMipmapLinear,
(GpuFilter.Linear, GpuMipFilter.Linear) => TextureMinFilter.LinearMipmapLinear,
_ => throw new NotSupportedException($"No GL min filter for {filter}/{mipFilter}."),
};
public static TextureMagFilter MagFilterOf(GpuFilter filter) => filter switch
{
GpuFilter.Nearest => TextureMagFilter.Nearest,
GpuFilter.Linear => TextureMagFilter.Linear,
_ => throw new NotSupportedException($"No GL mag filter for {filter}."),
};
public static TextureWrapMode WrapModeOf(GpuAddressMode addressMode) => addressMode switch
{
GpuAddressMode.Repeat => TextureWrapMode.Repeat,
GpuAddressMode.ClampToEdge => TextureWrapMode.ClampToEdge,
_ => throw new NotSupportedException($"No GL wrap mode for {addressMode}."),
};
}

View file

@ -1,287 +0,0 @@
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering.Gpu.Gl;
/// <summary>
/// A plain GL buffer object. Deliberately not one of the existing
/// <c>ManagedGL*</c> wrappers: those implement Chorizite's <c>IVertexBuffer</c>/
/// <c>IIndexBuffer</c> (generic-over-<c>IVertex</c>, single-usage) and are
/// constructed against <see cref="OpenGLGraphicsDevice"/>, which the campaign
/// explicitly sheds — <see cref="GlGpuDevice"/> is a fresh root. One GL buffer
/// object already serves every <see cref="GpuBufferUsage"/> combination (the
/// usage only matters at bind time), so a single small class covers the whole
/// <see cref="IGpuBuffer"/> surface without forking per usage.
///
/// Allocated once at the description's size via <c>glBufferData</c> with null
/// data. There are two write paths after that, and which one applies is a
/// property of the buffer's role rather than of the buffer object:
/// <see cref="Upload"/> is an ordinary synchronized <c>glBufferSubData</c>, used
/// for one-off and streaming writes the driver must order for us (the mesh
/// arena, texture staging); <see cref="WriteRangeUnsynchronized"/> maps the
/// range and is used only by the per-frame upload ring, whose non-overlap
/// invariant the caller can state. Neither path holds a persistent mapping.
///
/// The <c>glBufferData</c> usage hint follows
/// <see cref="GpuBufferDescription.Residency"/>:
/// <see cref="GpuMemoryResidency.DeviceLocal"/> is written rarely and read by
/// many draws, so it takes <c>StaticDraw</c>; the host-writable rings and
/// tables are rewritten every frame and take <c>DynamicDraw</c>. The hint is
/// advisory to the driver, but keeping it per-residency is what let the mesh
/// arena (Campaign V slice V4b) move onto this class without changing the
/// allocation it has always requested.
/// </summary>
internal sealed class GlGpuBuffer : IGpuBuffer
{
private readonly GL _gl;
private readonly IGpuResourceRetirementQueue _retirement;
private uint _name;
public GlGpuBuffer(GL gl, IGpuResourceRetirementQueue retirement, GpuBufferDescription description)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_retirement = retirement ?? throw new ArgumentNullException(nameof(retirement));
Name = description.Name;
SizeBytes = description.SizeBytes;
Usage = description.Usage;
Residency = description.Residency;
_name = GlResourceCommand.CreateName(_gl, $"buffer '{Name}'", _gl.GenBuffer, _gl.DeleteBuffer);
try
{
_gl.BindBuffer(GLEnum.CopyWriteBuffer, _name);
unsafe
{
_gl.BufferData(GLEnum.CopyWriteBuffer, (nuint)SizeBytes, null, UsageHintFor(Residency));
}
GLHelpers.ThrowOnResourceError(_gl, $"allocate buffer '{Name}' ({SizeBytes} bytes)");
_gl.BindBuffer(GLEnum.CopyWriteBuffer, 0);
}
catch
{
// A rejected data store (GL_OUT_OF_MEMORY is a real outcome for the
// mesh arena's 384 MiB growth destination) must not strand the name
// that was already created for it. The caller sees the original
// failure and owns nothing.
GlResourceCommand.DeleteBuffer(
_gl,
_name,
$"rollback buffer '{Name}' after a failed allocation");
_name = 0;
throw;
}
}
public string Name { get; }
public long SizeBytes { get; }
public GpuBufferUsage Usage { get; }
public GpuMemoryResidency Residency { get; }
/// <summary>The physical GL buffer name. For bind calls issued by <see cref="GlGpuPassEncoder"/>.</summary>
internal uint GlName => _name;
private static BufferUsageARB UsageHintFor(GpuMemoryResidency residency) =>
residency == GpuMemoryResidency.DeviceLocal
? BufferUsageARB.StaticDraw
: BufferUsageARB.DynamicDraw;
/// <summary>
/// Deletes the physical buffer on the calling thread instead of deferring it
/// through the device's retirement queue the way <see cref="Dispose"/> does.
///
/// Campaign V slice V4b: the mesh arena (<c>GlobalMeshBuffer</c>) already gates
/// every arena delete behind its own <c>GpuRetirementLedger</c> and decrements
/// its <c>MaximumPhysicalArenaBytes</c> accounting in the same retirement stage.
/// Routing the physical free through the queue a second time would delay it by
/// a further flight generation, so the arena's physical-capacity accounting
/// would run ahead of real GPU residency and could admit a migration that
/// breaches the 896 MiB dual-generation ceiling. A caller of this method must
/// therefore already have proved no submitted frame can still reference the
/// buffer.
///
/// Retryable by construction: the managed name is cleared only after the driver
/// reports success, so a failed delete is re-issued by the next attempt, and any
/// later call (including <see cref="Dispose"/>) is a no-op.
/// </summary>
internal void DeleteRetired(string context)
{
uint name = _name;
if (name == 0)
return;
_gl.DeleteBuffer(name);
// Per the GL error contract a command which generates an error does not
// change object state, so validation stays in the same stage as the
// mutation and the name is only surrendered once deletion committed.
GLHelpers.ThrowOnResourceError(_gl, context);
_name = 0;
}
public void Upload(long offsetBytes, ReadOnlySpan<byte> data)
{
ThrowIfDisposed();
if (offsetBytes < 0 || offsetBytes + data.Length > SizeBytes)
{
throw new ArgumentOutOfRangeException(
nameof(offsetBytes),
$"Upload of {data.Length} bytes at offset {offsetBytes} exceeds buffer '{Name}' ({SizeBytes} bytes).");
}
if (data.IsEmpty)
return;
_gl.BindBuffer(GLEnum.CopyWriteBuffer, _name);
unsafe
{
fixed (byte* pointer = data)
_gl.BufferSubData(GLEnum.CopyWriteBuffer, (nint)offsetBytes, (nuint)data.Length, pointer);
}
GLHelpers.ThrowOnResourceError(_gl, $"upload {data.Length} bytes to buffer '{Name}' at offset {offsetBytes}");
_gl.BindBuffer(GLEnum.CopyWriteBuffer, 0);
}
/// <summary>
/// Writes <paramref name="data"/> at <paramref name="offsetBytes"/> through
/// <c>glMapBufferRange</c> with
/// <c>GL_MAP_WRITE_BIT | GL_MAP_UNSYNCHRONIZED_BIT | GL_MAP_INVALIDATE_RANGE_BIT</c>
/// — the canonical GL upload-ring idiom, and the only write path the
/// per-frame ring uses.
///
/// <para><b>Why not <see cref="Upload"/>.</b> A partial <c>glBufferSubData</c>
/// into a buffer object that already-submitted draws are still reading leaves
/// the driver to guess how to keep both truths alive: it can stall, it can
/// rename the whole data store and copy the untouched remainder forward, or
/// it can route the write through an internal staging copy. Which one it
/// picks is a heuristic, and the heuristic is fed by the update pattern —
/// Campaign V's V4c revert (plan §5.5) saw a world that rendered blank
/// roughly one launch in three, with no GL error at any point, on the
/// connected path that issues 1040 such partial updates per frame, and zero
/// times on the offline path that issues 24. Mapping the range instead
/// removes the guess: the three bits together say "I am writing this range,
/// I am overwriting all of it, and nothing in flight reads it," which is
/// exactly the ring's actual invariant.</para>
///
/// <para><b>The precondition is the caller's to prove.</b>
/// <c>GL_MAP_UNSYNCHRONIZED_BIT</c> means the driver inserts no wait, so the
/// caller asserts that no GL command already submitted and not yet complete
/// reads any byte of <c>[offsetBytes, offsetBytes + data.Length)</c>. The ring
/// has that invariant on both axes: within a frame the allocation cursor only
/// moves forward and <see cref="GlRingBufferState"/> refuses a write below the
/// flushed high-water mark, and across frames a slot is only rewritten after
/// <c>GpuFrameFlightController.BeginFrame</c> has waited on the fence for the
/// last frame that used it. <c>GL_MAP_INVALIDATE_RANGE_BIT</c> is likewise
/// only sound because every byte of the mapped range is then written.</para>
///
/// <para>A <c>false</c> return from <c>glUnmapBuffer</c> means the data store
/// was lost while mapped (a GPU reset or display-mode change), so the write
/// did not land. That throws rather than being retried: the frame's uploads
/// are already partly gone, and silently continuing would draw from a
/// half-written ring.</para>
/// </summary>
internal unsafe void WriteRangeUnsynchronized(long offsetBytes, ReadOnlySpan<byte> data, string context)
{
ThrowIfDisposed();
if (offsetBytes < 0 || offsetBytes + data.Length > SizeBytes)
{
throw new ArgumentOutOfRangeException(
nameof(offsetBytes),
$"Mapped write of {data.Length} bytes at offset {offsetBytes} exceeds buffer '{Name}' ({SizeBytes} bytes).");
}
if (data.IsEmpty)
return;
_gl.BindBuffer(GLEnum.CopyWriteBuffer, _name);
void* mapped = _gl.MapBufferRange(
GLEnum.CopyWriteBuffer,
(nint)offsetBytes,
(nuint)data.Length,
MapBufferAccessMask.WriteBit
| MapBufferAccessMask.UnsynchronizedBit
| MapBufferAccessMask.InvalidateRangeBit);
if (mapped is null)
{
// A null return always sets a GL error, so surface that first — it
// names the actual rejection instead of the symptom.
GLHelpers.ThrowOnResourceError(
_gl,
$"map {data.Length} bytes of buffer '{Name}' at offset {offsetBytes} ({context})");
throw new InvalidOperationException(
$"glMapBufferRange returned no pointer for {data.Length} bytes of buffer '{Name}' " +
$"at offset {offsetBytes} ({context}).");
}
data.CopyTo(new Span<byte>(mapped, data.Length));
bool unmapped = _gl.UnmapBuffer(GLEnum.CopyWriteBuffer);
GLHelpers.ThrowOnResourceError(_gl, $"unmap buffer '{Name}' after writing {context}");
_gl.BindBuffer(GLEnum.CopyWriteBuffer, 0);
if (!unmapped)
{
throw new InvalidOperationException(
$"Buffer '{Name}' lost its data store while {data.Length} bytes at offset " +
$"{offsetBytes} ({context}) were mapped; the write did not land.");
}
}
public void CopyTo(IGpuBuffer destination, long sourceOffsetBytes, long destinationOffsetBytes, long byteCount)
{
ThrowIfDisposed();
ArgumentNullException.ThrowIfNull(destination);
if (destination is not GlGpuBuffer target)
throw new ArgumentException("A GL device can only copy into a GL buffer.", nameof(destination));
if (byteCount == 0)
return;
_gl.BindBuffer(GLEnum.CopyReadBuffer, _name);
_gl.BindBuffer(GLEnum.CopyWriteBuffer, target._name);
_gl.CopyBufferSubData(
GLEnum.CopyReadBuffer,
GLEnum.CopyWriteBuffer,
(nint)sourceOffsetBytes,
(nint)destinationOffsetBytes,
(nuint)byteCount);
GLHelpers.ThrowOnResourceError(
_gl,
$"copy {byteCount} bytes from buffer '{Name}' to '{target.Name}'");
_gl.BindBuffer(GLEnum.CopyReadBuffer, 0);
_gl.BindBuffer(GLEnum.CopyWriteBuffer, 0);
}
public void Read(long offsetBytes, Span<byte> destination)
{
ThrowIfDisposed();
if (destination.IsEmpty)
return;
_gl.BindBuffer(GLEnum.CopyReadBuffer, _name);
unsafe
{
fixed (byte* pointer = destination)
_gl.GetBufferSubData(GLEnum.CopyReadBuffer, (nint)offsetBytes, (nuint)destination.Length, pointer);
}
GLHelpers.ThrowOnResourceError(_gl, $"read {destination.Length} bytes from buffer '{Name}' at offset {offsetBytes}");
_gl.BindBuffer(GLEnum.CopyReadBuffer, 0);
}
public void Dispose()
{
uint name = _name;
if (name == 0)
return;
_name = 0;
GL gl = _gl;
string label = Name;
_retirement.Retire(() =>
{
gl.DeleteBuffer(name);
GLHelpers.ThrowOnResourceError(gl, $"delete buffer '{label}' ({name})");
});
}
private void ThrowIfDisposed()
{
if (_name == 0)
throw new ObjectDisposedException(Name);
}
}

View file

@ -1,667 +0,0 @@
using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.App.Diagnostics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering.Gpu.Gl;
/// <summary>
/// OpenGL 4.3 implementation of <see cref="IGpuDevice"/> — Campaign V slice V1.
/// See <c>docs/plans/2026-07-27-vulkan-campaign.md</c> §3 for the pinned
/// contract this backend fills and §5 for what this slice covers.
///
/// Deliberately NOT derived from Chorizite's <c>BaseGraphicsDevice</c>/
/// <see cref="OpenGLGraphicsDevice"/> — this is a fresh root, which is one of
/// the things Campaign V sheds. It owns its own <see cref="BindlessSupport"/>
/// instance (created in the constructor) rather than sharing the legacy WB
/// render path's; both simply wrap the same stateless
/// <c>GL_ARB_bindless_texture</c> extension, so two instances coexist safely,
/// and it lets this device be constructed the moment a GL context and a
/// <see cref="GpuFrameFlightController"/> exist — no dependency on when the
/// legacy path happens to detect bindless support during composition.
///
/// <para><b>Ring capacity.</b> Each flight slot gets one managed staging
/// <c>byte[]</c> and one same-sized GL buffer, both fixed at construction
/// (default 16 MiB — see <see cref="DefaultRingCapacityBytesPerSlot"/>). V1
/// chose "throw with a message naming the needed size" over "grow by create-
/// copy-retire" for an over-capacity request: nothing consumes this device
/// yet, so there is no real per-frame data volume to size against, and a
/// silent/implicit grow would hide a future renderer's actual working set
/// from the person porting it. <see cref="GlRingBufferState"/> is the pure
/// piece that enforces this.</para>
///
/// <para><b>Flush discipline.</b> Ring bytes and the texture-handle table are
/// both flushed immediately before every
/// <c>Draw</c>/<c>DrawIndexed</c>/<c>MultiDrawIndexedIndirect</c> — see
/// <see cref="FlushBeforeDraw"/> — never at bind time, so a renderer that
/// writes after binding still uploads correctly. Both flushes go through
/// <see cref="GlGpuBuffer.WriteRangeUnsynchronized"/>, i.e.
/// <c>glMapBufferRange</c> with
/// <c>GL_MAP_WRITE_BIT | GL_MAP_UNSYNCHRONIZED_BIT | GL_MAP_INVALIDATE_RANGE_BIT</c>,
/// rather than <c>glBufferSubData</c>: these are the only two buffers this
/// backend rewrites while the frame's own draws are still in flight, and that
/// is precisely the case where a partial <c>glBufferSubData</c> leaves the
/// result to a driver heuristic. See that method's remarks and campaign plan
/// §5.5 for the failure it produced. The two flushes differ in shape because
/// their non-overlap proofs differ: the ring writes one span (its cursor only
/// moves forward within a frame), the table writes one span per run of
/// consecutive dirty slots (see <see cref="GlDirtySlotRuns"/>).</para>
/// </summary>
internal sealed class GlGpuDevice : IGpuDevice
{
internal const int DefaultRingCapacityBytesPerSlot = 16 * 1024 * 1024;
// GL tokens not exposed as named Silk.NET GetPName members (mirrors the
// same raw-hex pattern GraphicalCapabilityRecord.cs already uses for
// GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS).
private const int GlMaxShaderStorageBufferBindings = 0x90DD;
private const int GlMaxClipDistances = 0x0D32;
private const int GlMaxSamples = 0x8D57;
private const int GlShaderStorageBufferOffsetAlignment = 0x90DF;
private readonly GL _gl;
private readonly GpuFrameFlightController _frameFlights;
private readonly BindlessSupport _bindless;
private readonly string _shadersDirectory;
private readonly int _ringCapacityBytesPerSlot;
private readonly GlRingBufferState[] _ringStates;
private readonly byte[][] _ringStaging;
private readonly GlGpuBuffer[] _ringBuffers;
private readonly GlTextureSlotAllocator _textureSlotAllocator =
new(GpuBindingModel.TextureTableCapacity);
private readonly ulong[] _textureHandleTable = new ulong[GpuBindingModel.TextureTableCapacity];
private readonly GlGpuBuffer _textureTableBuffer;
private readonly GlDirtySlotRuns _textureTableDirtySlots =
new(GpuBindingModel.TextureTableCapacity);
private readonly GlRenderStateCache _renderState = new();
private readonly GlGpuPushConstantBinder _pushConstants;
private readonly GlGpuTimerPool _timerPool;
private readonly Dictionary<GpuSamplerDescription, GlGpuSampler> _samplers = [];
private readonly List<Action> _queuedActions = [];
private readonly GlGpuTexture _defaultTexture;
private long _nextSerial;
private bool _disposed;
public GlGpuDevice(
GL gl,
GpuFrameFlightController frameFlights,
string shadersDirectory,
int ringCapacityBytesPerSlot = DefaultRingCapacityBytesPerSlot)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_frameFlights = frameFlights ?? throw new ArgumentNullException(nameof(frameFlights));
ArgumentException.ThrowIfNullOrWhiteSpace(shadersDirectory);
_shadersDirectory = shadersDirectory;
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(ringCapacityBytesPerSlot);
_ringCapacityBytesPerSlot = ringCapacityBytesPerSlot;
if (!BindlessSupport.TryCreate(_gl, out BindlessSupport? bindless) || bindless is null)
{
throw new NotSupportedException(
"GlGpuDevice requires GL_ARB_bindless_texture. The startup capability gate " +
"(GraphicalCapabilityGuard) should already have rejected a driver without it, " +
"so reaching this means the device was constructed before that gate ran.");
}
_bindless = bindless;
Capabilities = CaptureCapabilities();
int slotCount = _frameFlights.SlotCount;
_ringStates = new GlRingBufferState[slotCount];
_ringStaging = new byte[slotCount][];
_ringBuffers = new GlGpuBuffer[slotCount];
for (int slot = 0; slot < slotCount; slot++)
{
_ringStates[slot] = new GlRingBufferState(_ringCapacityBytesPerSlot);
_ringStaging[slot] = new byte[_ringCapacityBytesPerSlot];
_ringBuffers[slot] = new GlGpuBuffer(
_gl,
Retirement,
new GpuBufferDescription(
$"gpu-ring-slot-{slot}",
_ringCapacityBytesPerSlot,
GpuBufferUsage.Storage | GpuBufferUsage.Uniform | GpuBufferUsage.Indirect,
GpuMemoryResidency.HostWritable));
}
_textureTableBuffer = new GlGpuBuffer(
_gl,
Retirement,
new GpuBufferDescription(
"gpu-texture-table",
GpuBindingModel.TextureTableCapacity * sizeof(ulong),
GpuBufferUsage.Storage,
GpuMemoryResidency.HostWritable));
_pushConstants = new GlGpuPushConstantBinder(_gl);
_timerPool = new GlGpuTimerPool(new SilkGlTimerQueryApi(_gl), Capabilities.SupportsTimestampQueries);
_defaultTexture = new GlGpuTexture(
_gl,
Retirement,
new GpuTextureDescription(
"default-white",
GpuTextureKind.Texture2D,
GpuTextureFormat.Rgba8Unorm,
Width: 1,
Height: 1,
LayerCount: 1,
MipLevelCount: 1));
_defaultTexture.Upload(0, 0, [255, 255, 255, 255]);
IGpuSampler defaultSampler = CreateSampler(GpuSamplerDescription.UiNearest);
DefaultTextureSlot = RegisterTexture(_defaultTexture, defaultSampler);
}
public GpuBackendKind Backend => GpuBackendKind.OpenGl;
public GpuCapabilityRecord Capabilities { get; }
public IGpuResourceRetirementQueue Retirement => _frameFlights;
public IGpuTimerPool Timers => _timerPool;
public GpuTextureSlot DefaultTextureSlot { get; }
internal GL Gl => _gl;
internal GlGpuPushConstantBinder PushConstants => _pushConstants;
internal GlGpuTimerPool TimerPool => _timerPool;
/// <summary>
/// GL name of the buffer emulating the global texture table
/// (<see cref="GpuBindingModel.StorageTextureTable"/>). Slice V6d:
/// <see cref="GlGpuPassEncoder.BindPipeline"/> binds it on every pipeline
/// bind, which is the GL analogue of the Vulkan backend binding descriptor
/// set 2 on every draw. Without it an RHI shader that samples the table
/// would read whichever raw-GL renderer's private handle table was left at
/// binding 9 — a different slot numbering entirely, which is the loudest
/// possible way to sample the wrong texture.
/// </summary>
internal uint TextureTableGlName => _textureTableBuffer.GlName;
internal GlRenderStateSnapshot CurrentRenderState { get; private set; }
public IGpuBuffer CreateBuffer(in GpuBufferDescription description)
{
ThrowIfDisposed();
return new GlGpuBuffer(_gl, Retirement, description);
}
public IGpuTexture CreateTexture(in GpuTextureDescription description)
{
ThrowIfDisposed();
return new GlGpuTexture(_gl, Retirement, description);
}
public IGpuSampler CreateSampler(in GpuSamplerDescription description)
{
ThrowIfDisposed();
if (_samplers.TryGetValue(description, out GlGpuSampler? existing))
return existing;
var created = new GlGpuSampler(_gl, Retirement, description);
_samplers.Add(description, created);
return created;
}
public IGpuPipeline CreatePipeline(GpuPipelineDescription description)
{
ThrowIfDisposed();
ArgumentNullException.ThrowIfNull(description);
string vertexPath = Path.Combine(_shadersDirectory, $"{description.Shaders.Name}.vert");
string fragmentPath = Path.Combine(_shadersDirectory, $"{description.Shaders.Name}.frag");
// Campaign V slice V6d: every RHI pipeline gets the shared preamble,
// unconditionally. The Vulkan backend injects its own preamble into
// every shader it compiles, so making the GL side selective would mean
// one source file compiling against two different sets of definitions
// depending on which pipeline happened to ask for it. A shader that
// reads nothing from the preamble simply carries an unused declaration.
string common = File.ReadAllText(Path.Combine(_shadersDirectory, "common.glsl"));
string vertexSource = Shader.InjectPreamble(File.ReadAllText(vertexPath), common);
string fragmentSource = Shader.InjectPreamble(File.ReadAllText(fragmentPath), common);
return new GlGpuPipeline(_gl, Retirement, description, vertexSource, fragmentSource);
}
public IGpuRenderTarget CreateRenderTarget(in GpuRenderTargetDescription description)
{
ThrowIfDisposed();
return new GlGpuRenderTarget(_gl, Retirement, description);
}
public GpuTextureSlot RegisterTexture(IGpuTexture texture, IGpuSampler sampler)
{
ThrowIfDisposed();
ArgumentNullException.ThrowIfNull(texture);
ArgumentNullException.ThrowIfNull(sampler);
if (texture is not GlGpuTexture glTexture)
throw new ArgumentException("The GL backend can only register a GL texture.", nameof(texture));
if (sampler is not GlGpuSampler glSampler)
throw new ArgumentException("The GL backend can only register a GL sampler.", nameof(sampler));
uint slot = _textureSlotAllocator.Allocate();
ulong handle = _bindless.GetResidentHandle(glTexture.GlName, glSampler.GlName);
WriteHandle(slot, handle);
return new GpuTextureSlot(slot);
}
public void ReleaseTextureSlot(GpuTextureSlot slot)
{
ThrowIfDisposed();
if (!slot.IsAssigned)
throw new ArgumentException("Cannot release an unassigned texture slot.", nameof(slot));
uint index = slot.Index;
// Deferred through the retirement queue: a submitted-but-not-yet-
// retired frame may still read this slot's handle, so the free list
// (and thus reuse) must wait for that frame to retire.
Retirement.Retire(() =>
{
WriteHandle(index, 0);
_textureSlotAllocator.Release(index);
});
}
public IGpuFrame BeginFrame()
{
ThrowIfDisposed();
_frameFlights.BeginFrame();
long serial = ++_nextSerial;
int slot = _frameFlights.CurrentSlot;
_ringStates[slot].Reset();
return new GlGpuFrame(this, slot, serial);
}
internal void EndFrame() => _frameFlights.EndFrame();
internal GpuRingAllocation AllocateRing(int slotIndex, int byteCount, GpuRingUsage usage)
{
uint alignment = usage switch
{
GpuRingUsage.Storage => Capabilities.MinStorageBufferOffsetAlignment,
GpuRingUsage.Uniform => Capabilities.MinUniformBufferOffsetAlignment,
_ => 4u,
};
uint offset = _ringStates[slotIndex].Allocate(byteCount, alignment);
Span<byte> data = byteCount == 0
? Span<byte>.Empty
: _ringStaging[slotIndex].AsSpan((int)offset, byteCount);
return new GpuRingAllocation(_ringBuffers[slotIndex], offset, data);
}
/// <summary>
/// Writes this slot's dirty ring bytes and every dirty run of the texture
/// table into their GL buffers, then clears both watermarks. Called
/// immediately before every draw — never at bind time, because a renderer
/// may still write into a ring allocation after binding it.
///
/// Both writes are mapped and unsynchronized (see the class remarks and
/// <see cref="GlGpuBuffer.WriteRangeUnsynchronized"/>). The ring's dirty
/// span may include alignment padding between allocations; those bytes are
/// below the cursor, were allocated this frame, and are read by nothing, so
/// covering them in one map is sound and saves a call. The table has no
/// equivalent licence, which is why it drains run by run.
/// </summary>
internal void FlushBeforeDraw(int slotIndex)
{
(int start, int length) = _ringStates[slotIndex].TakeDirtyRange();
if (length > 0)
{
_ringBuffers[slotIndex].WriteRangeUnsynchronized(
start,
_ringStaging[slotIndex].AsSpan(start, length),
$"ring slot {slotIndex}");
}
FlushTextureTable();
}
/// <summary>
/// Drains every dirty run of the texture table into its GL buffer. Called by
/// <see cref="FlushBeforeDraw"/> for RHI draws, and directly by the still-raw-GL
/// world renderers before their own draws — see the V4t remarks on
/// <see cref="RegisterWorldTextureHandle"/>.
/// </summary>
internal void FlushTextureTable()
{
while (_textureTableDirtySlots.TryTakeNextRun(out uint firstSlot, out uint slotCount))
{
ReadOnlySpan<byte> bytes = MemoryMarshal.AsBytes(
_textureHandleTable.AsSpan((int)firstSlot, (int)slotCount));
_textureTableBuffer.WriteRangeUnsynchronized(
(long)firstSlot * sizeof(ulong),
bytes,
$"texture table slots {firstSlot}..{firstSlot + slotCount - 1}");
}
}
internal void BeginPass(GpuPassDescription description)
{
ThrowIfDisposed();
ArgumentNullException.ThrowIfNull(description);
if (description.Color.Store == GpuStoreOp.Resolve
|| description.Depth is { Store: GpuStoreOp.Resolve })
{
throw new NotSupportedException(
"GpuStoreOp.Resolve is a Vulkan-only path; Campaign V slice V1's GL backend " +
"only ever renders single-sampled targets.");
}
uint framebuffer = 0;
if (description.Color.Target is { } target)
{
if (target is not GlGpuRenderTarget glTarget)
throw new ArgumentException("The GL backend can only render into a GL render target.");
framebuffer = glTarget.GlFramebufferName;
}
_gl.BindFramebuffer(GLEnum.Framebuffer, framebuffer);
bool clearsColor = description.Color.Load == GpuLoadOp.Clear;
bool clearsDepth = description.Depth is { Load: GpuLoadOp.Clear };
if (clearsColor || clearsDepth)
{
// Force the write masks on before clearing, regardless of what
// the previous pass's last draw left them at (e.g. depth-write
// disabled mid-translucent-pass) — glClear silently no-ops for a
// buffer whose mask is off.
ClearBufferMask mask = 0;
if (clearsColor)
{
_gl.ColorMask(true, true, true, true);
Vector4 clearColor = description.Color.ClearColor;
_gl.ClearColor(clearColor.X, clearColor.Y, clearColor.Z, clearColor.W);
mask |= ClearBufferMask.ColorBufferBit;
}
if (description.Depth is { Load: GpuLoadOp.Clear } depth)
{
_gl.DepthMask(true);
_gl.ClearDepth(depth.ClearDepth);
_gl.ClearStencil((int)depth.ClearStencil);
mask |= ClearBufferMask.DepthBufferBit | ClearBufferMask.StencilBufferBit;
}
_gl.Clear(mask);
GLHelpers.ThrowOnResourceError(_gl, $"clear pass '{description.Name}'");
}
// Campaign V slice V4a (2026-07-27 revert postmortem, plan §7.1 rule 2):
// reset unconditionally on EVERY pass, not only a clearing one. While
// raw-GL renderers coexist with RHI-ported ones (through V4h), a
// raw-GL renderer can run between two RHI passes within the same frame
// and change GL program/blend/depth/cull state the cache never
// observes. A reset gated on "this pass cleared" left the cache
// trusting a stale belief in that case, so a later BindPipeline
// skipped re-issuing glUseProgram and the following push-constant
// upload threw GL_INVALID_OPERATION against whatever program was
// actually bound — exactly the failure the first V4a attempt hit.
// Resetting on every BeginPass costs one redundant state application
// on the pass's first bind and is removed at V4h once nothing raw-GL
// remains.
_renderState.Reset();
}
// Campaign V slice V6k deleted the V4a pre-approved transitional seam
// (RegisterExternalColorTexture / TryResolveExternalColorTexture, campaign
// doc §7.1's final paragraph). It existed so the retained UI could blit the
// paperdoll and creature-appraisal viewport textures while their renderer
// still owned a hand-rolled FBO the RHI knew nothing about. That renderer now
// creates an IGpuRenderTarget and registers its colour attachment through
// RegisterTexture like anything else, so the escape hatch has no caller —
// exactly the end §7.1 wrote for it.
// ── V4t transitional seam: the world texture stack's entry to this table ──
//
// Campaign V slice V4t moves the world's CPU data model off the raw 64-bit
// ARB_bindless_texture handle and onto GpuTextureSlot, but §5.5.6 closed the
// GL re-land of V4c/V4d, so WbDrawDispatcher, EnvCellRenderer,
// TerrainModernRenderer and ParticleRenderer still submit through raw GL and
// cannot reach FlushBeforeDraw. They therefore call FlushTextureTable and
// bind TextureTableGlName at GpuBindingModel.StorageTextureTable themselves,
// immediately before their own draws — the same shape their retired private
// GlBindlessHandleTable had, against the one table that is now the device's.
//
// Residency ownership does NOT move. Each world texture is created, made
// resident and destroyed by its own cache (TerrainAtlas,
// CompositeTextureArrayCache, StandaloneBindlessTextureCache,
// ManagedGLTextureArray); this device only owns the table entry, keyed 1:1 by
// the caller's already-resident handle. That is what separates this from
// RegisterTexture, which owns the residency it creates.
//
// Deleted with the raw-GL world path when the Vulkan world arm lands, at
// which point every one of those caches registers through RegisterTexture.
private readonly Dictionary<ulong, GpuTextureSlot> _worldTextureSlotsByHandle = new();
/// <summary>
/// Interns an already-resident world texture handle into the device's table
/// and returns its slot. Idempotent: the same handle always resolves to the
/// same slot until <see cref="ReleaseWorldTextureHandle"/> retires it, which
/// is what lets a cache call this per draw rather than tracking the slot.
/// </summary>
internal GpuTextureSlot RegisterWorldTextureHandle(ulong residentHandle)
{
ThrowIfDisposed();
if (residentHandle == 0)
return GpuTextureSlot.Unassigned;
if (_worldTextureSlotsByHandle.TryGetValue(residentHandle, out GpuTextureSlot existing))
return existing;
uint slotIndex = _textureSlotAllocator.Allocate();
WriteHandle(slotIndex, residentHandle);
var slot = new GpuTextureSlot(slotIndex);
_worldTextureSlotsByHandle.Add(residentHandle, slot);
return slot;
}
/// <summary>
/// Retires the table entry for a world handle the caller is about to make
/// non-resident. The slot itself returns to the free list only once the
/// retirement queue confirms no submitted frame can still read it, exactly
/// as for <see cref="ReleaseTextureSlot"/>. A handle that was never
/// registered is a no-op, so a cache may call this unconditionally on its
/// teardown path.
/// </summary>
internal void ReleaseWorldTextureHandle(ulong residentHandle)
{
if (residentHandle == 0)
return;
if (!_worldTextureSlotsByHandle.Remove(residentHandle, out GpuTextureSlot slot))
return;
ReleaseTextureSlot(slot);
}
/// <summary>Live world-handle registrations. Diagnostics and tests only.</summary>
internal int WorldTextureSlotCount => _worldTextureSlotsByHandle.Count;
internal void ApplyRenderState(GlRenderStateSnapshot desired)
{
GlRenderStateChanges changes = _renderState.Apply(desired);
CurrentRenderState = desired;
if (!changes.AnyChange)
return;
if (changes.Program)
_gl.UseProgram(desired.Program);
if (changes.Blend)
{
if (desired.Blend == GpuBlendMode.None)
{
_gl.Disable(EnableCap.Blend);
}
else
{
_gl.Enable(EnableCap.Blend);
(BlendingFactor source, BlendingFactor destination) = GlEnumMapping.BlendFactorsOf(desired.Blend);
_gl.BlendFunc(source, destination);
}
}
if (changes.DepthTest)
{
if (desired.DepthTest)
_gl.Enable(EnableCap.DepthTest);
else
_gl.Disable(EnableCap.DepthTest);
}
if (changes.DepthWrite)
_gl.DepthMask(desired.DepthWrite);
if (changes.DepthCompare)
_gl.DepthFunc(GlEnumMapping.DepthFunctionOf(desired.DepthCompare));
if (changes.Cull)
{
if (desired.Cull == GpuCullMode.None)
{
_gl.Disable(EnableCap.CullFace);
}
else
{
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(GlEnumMapping.CullFaceModeOf(desired.Cull));
}
}
if (changes.FrontFace)
_gl.FrontFace(GlEnumMapping.FrontFaceDirectionOf(desired.FrontFace));
if (changes.AlphaToCoverage)
{
if (desired.AlphaToCoverage)
_gl.Enable(EnableCap.SampleAlphaToCoverage);
else
_gl.Disable(EnableCap.SampleAlphaToCoverage);
}
if (changes.ColorWrite)
{
_gl.ColorMask(desired.ColorWrite, desired.ColorWrite, desired.ColorWrite, desired.ColorWrite);
}
// Slice V6l. The stencil VALUES are issued whenever the test is on, even
// if only the enable changed: GL keeps func/op/mask as global state that a
// raw-GL renderer may have moved since this cache last saw it, and the
// portal punch's correctness depends on the exact triple it asked for.
if (changes.StencilTest)
{
if (desired.StencilTest)
_gl.Enable(EnableCap.StencilTest);
else
_gl.Disable(EnableCap.StencilTest);
}
if (desired.StencilTest && (changes.StencilTest || changes.Stencil))
{
GpuStencilState stencil = desired.Stencil;
_gl.StencilFunc(
GlEnumMapping.StencilFunctionOf(stencil.Compare),
(int)stencil.Reference,
stencil.CompareMask);
_gl.StencilOp(
GlEnumMapping.StencilOpOf(stencil.Fail),
GlEnumMapping.StencilOpOf(stencil.DepthFail),
GlEnumMapping.StencilOpOf(stencil.Pass));
_gl.StencilMask(stencil.WriteMask);
}
GLHelpers.ThrowOnResourceError(_gl, "apply GL render state");
}
public void QueueDeviceAction(Action action)
{
ArgumentNullException.ThrowIfNull(action);
_queuedActions.Add(action);
}
public void ProcessDeviceActions()
{
Action[] pending = [.. _queuedActions];
_queuedActions.Clear();
foreach (Action action in pending)
action();
}
public byte[] CaptureBackbuffer(int width, int height)
{
ThrowIfDisposed();
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height);
// FrameScreenshotController owns the one backbuffer read in the process:
// it names framebuffer 0 rather than inheriting a binding, and it
// resolves the multisampled default framebuffer before reading, because
// glReadPixels against a multisampled read framebuffer is undefined.
// A second implementation here would be a second instrument to keep
// sound. FlipRows is the same top-left-origin flip the screenshot gates
// rely on, so this seam stays byte-for-byte compatible with them.
byte[] pixels = FrameScreenshotController.ReadDefaultFramebuffer(_gl, width, height);
GLHelpers.ThrowOnResourceError(_gl, $"capture backbuffer {width}x{height}");
return FrameScreenshotController.FlipRows(pixels, width, height);
}
public void WaitIdle()
{
ThrowIfDisposed();
_frameFlights.WaitForSubmittedWork();
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
// Only resources THIS device created are disposed here.
// GpuFrameFlightController is constructor-injected and owned by
// whoever composed this device — disposing it would be a double-
// dispose from that owner's perspective, and every disposal below
// routes through it as the retirement queue, so it must still be
// alive when this method returns.
foreach (GlGpuSampler sampler in _samplers.Values)
sampler.Dispose();
_samplers.Clear();
_defaultTexture.Dispose();
foreach (GlGpuBuffer ringBuffer in _ringBuffers)
ringBuffer.Dispose();
_textureTableBuffer.Dispose();
_timerPool.DisposeQueries();
}
private void WriteHandle(uint slot, ulong handle)
{
_textureHandleTable[slot] = handle;
_textureTableDirtySlots.Mark(slot);
}
private GpuCapabilityRecord CaptureCapabilities()
{
int major = _gl.GetInteger(GetPName.MajorVersion);
int minor = _gl.GetInteger(GetPName.MinorVersion);
bool openGl43 = major > 4 || (major == 4 && minor >= 3);
_gl.GetInteger((GetPName)GlMaxShaderStorageBufferBindings, out int maxStorageBindings);
_gl.GetInteger((GetPName)GlMaxClipDistances, out int maxClipDistances);
_gl.GetInteger((GetPName)GlMaxSamples, out int maxSamples);
_gl.GetInteger(GetPName.UniformBufferOffsetAlignment, out int uniformAlignment);
_gl.GetInteger((GetPName)GlShaderStorageBufferOffsetAlignment, out int storageAlignment);
bool timerQuery = major > 3 || (major == 3 && minor >= 3) || _gl.IsExtensionPresent("GL_ARB_timer_query");
return new GpuCapabilityRecord
{
Backend = GpuBackendKind.OpenGl,
DeviceName = _gl.GetStringS(GLEnum.Renderer),
DriverInfo = _gl.GetStringS(GLEnum.Vendor),
ApiVersion = $"OpenGL {_gl.GetStringS(GLEnum.Version)}",
MaxTextureTableSlots = GpuBindingModel.TextureTableCapacity,
MaxStorageBufferBindings = (uint)maxStorageBindings,
MaxPushConstantBytes = (uint)GpuBindingModel.MaxPushConstantBytes,
MinStorageBufferOffsetAlignment = (uint)storageAlignment,
MinUniformBufferOffsetAlignment = (uint)uniformAlignment,
MaxClipDistances = (uint)maxClipDistances,
MaxSampleCount = (uint)maxSamples,
SupportsMultiDrawIndirect = openGl43 || _gl.IsExtensionPresent("GL_ARB_multi_draw_indirect"),
SupportsDrawParameters = _gl.IsExtensionPresent("GL_ARB_shader_draw_parameters"),
SupportsTextureCompressionBc = _gl.IsExtensionPresent("GL_EXT_texture_compression_s3tc"),
SupportsTimestampQueries = timerQuery,
// The ring maps and unmaps per flush; it never holds a persistent
// mapping a renderer could write into between frames, which is what
// this capability advertises. Only Vulkan reports true.
SupportsPersistentlyMappedRings = false,
};
}
private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this);
}

View file

@ -1,58 +0,0 @@
namespace AcDream.App.Rendering.Gpu.Gl;
/// <summary>
/// One frame's recording context on the GL backend. Ring allocations are
/// forwarded to the device's per-slot <see cref="GlRingBufferState"/>;
/// passes are single-level (no nesting, matching every acdream renderer
/// today) and enforced here.
/// </summary>
internal sealed class GlGpuFrame : IGpuFrame
{
private readonly GlGpuDevice _device;
private bool _ended;
private GlGpuPassEncoder? _openPass;
internal GlGpuFrame(GlGpuDevice device, int slotIndex, long serial)
{
_device = device;
SlotIndex = slotIndex;
Serial = serial;
}
public int SlotIndex { get; }
public long Serial { get; }
public GpuRingAllocation AllocateRing(int byteCount, GpuRingUsage usage) =>
_device.AllocateRing(SlotIndex, byteCount, usage);
public IGpuPassEncoder BeginPass(GpuPassDescription description)
{
ArgumentNullException.ThrowIfNull(description);
if (_openPass is not null)
{
throw new InvalidOperationException(
"A pass is already open on this frame; dispose it before beginning another.");
}
_device.BeginPass(description);
var encoder = new GlGpuPassEncoder(_device, this, description);
_openPass = encoder;
return encoder;
}
internal void ClosePass(GlGpuPassEncoder encoder)
{
if (ReferenceEquals(_openPass, encoder))
_openPass = null;
}
public void End()
{
if (_ended)
return;
_ended = true;
_device.EndFrame();
}
public void Dispose() => End();
}

View file

@ -1,316 +0,0 @@
using AcDream.App.Rendering.Wb;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering.Gpu.Gl;
/// <summary>
/// Records one pass's draw work. Binding calls translate almost mechanically
/// to GL (a storage/uniform binding is <c>glBindBufferRange</c>, an indexed
/// draw is <c>glDrawElementsInstancedBaseVertexBaseInstance</c>, and so on);
/// the two pieces of real logic are the render-state diff applied on
/// <see cref="BindPipeline"/> / the dynamic setters, and the "flush dirty
/// ring + texture-table bytes immediately before every draw" discipline
/// described on <see cref="GlGpuDevice"/>.
/// </summary>
internal sealed class GlGpuPassEncoder : IGpuPassEncoder
{
private readonly GlGpuDevice _device;
private readonly GlGpuFrame _frame;
private readonly GL _gl;
private readonly IGlAmbientStateApi _ambientApi;
private readonly GlAmbientCapabilityState _ambientOnEntry;
private bool _closed;
private GlGpuPipeline? _currentPipeline;
private GpuIndexType _currentIndexType = GpuIndexType.UInt16;
private uint _currentIndexBufferBaseOffset;
private GpuPushConstants? _currentPushConstants;
internal GlGpuPassEncoder(GlGpuDevice device, GlGpuFrame frame, GpuPassDescription pass)
{
_device = device;
_frame = frame;
_gl = device.Gl;
Pass = pass;
// Campaign V slice V4a (2026-07-27 revert postmortem, plan §7.1 rule 1):
// capture every ambient capability a bound pipeline can change, so
// Dispose can put it back. Every acdream renderer is still raw GL
// until V4c/V4d, so each one assumes whatever capability state the
// PREVIOUS renderer left behind is still there — GL_MULTISAMPLE and
// GL_SAMPLE_ALPHA_TO_COVERAGE in particular are set once per frame by
// quality settings and never re-asserted per draw. The first V4a
// attempt bound a pipeline that changed this state and never restored
// it, so the world drew without multisampling from the first UI frame
// on. Capturing here and restoring on Dispose keeps the GL backend's
// behaviour-preserving property true at this seam. Deleted at V4h
// once nothing raw-GL remains.
_ambientApi = new SilkGlAmbientStateApi(_gl);
_ambientOnEntry = GlAmbientCapabilityState.Capture(_ambientApi);
// Campaign V slice V6d. GL_MULTISAMPLE is the one piece of pass state
// with no representation in GpuPipelineDescription, and the pass's own
// SampleCount is the contract's answer for it: a single-sampled pass
// does not multisample. Until now the retained UI asserted that with a
// raw glDisable of its own — exactly the kind of state a
// backend-neutral renderer cannot own. Quality settings enable
// GL_MULTISAMPLE once per frame for the world, and if it leaks into the
// UI pass every glyph's soft alpha edge becomes dithered coverage
// instead of a clean alpha blend (the "fuzzy text" artifact). The
// ambient capture above puts it back on Dispose, so the raw-GL world
// renderers that follow are unaffected.
_ambientApi.SetCapability(EnableCap.Multisample, pass.SampleCount > 1);
}
public GpuPassDescription Pass { get; }
public void BindPipeline(IGpuPipeline pipeline)
{
ArgumentNullException.ThrowIfNull(pipeline);
ThrowIfClosed();
var p = (GlGpuPipeline)pipeline;
_currentPipeline = p;
GpuPipelineDescription description = p.Description;
var desired = new GlRenderStateSnapshot(
p.GlProgram,
description.Blend,
description.Depth.Test,
description.Depth.Write,
description.Depth.Compare,
description.Cull,
description.FrontFace,
description.AlphaToCoverage,
description.ColorWrite,
description.StencilTest,
description.Stencil);
_device.ApplyRenderState(desired);
_gl.BindVertexArray(p.GlVertexArray);
GLHelpers.ThrowOnResourceError(_gl, $"bind pipeline '{description.Name}' VAO");
// Campaign V slice V6d: the device's texture table is bound with the
// pipeline, the GL analogue of the Vulkan backend binding descriptor
// set 2 on every draw. It has to happen here rather than once per frame
// because every raw-GL world renderer binds its OWN private handle
// table at this same binding before its own draws, with its own slot
// numbering; an RHI shader that read that instead would sample a
// plausible but entirely unrelated texture. Removed at V4h with the
// per-renderer tables.
_gl.BindBufferBase(
GLEnum.ShaderStorageBuffer,
GpuBindingModel.StorageTextureTable,
_device.TextureTableGlName);
GLHelpers.ThrowOnResourceError(_gl, $"bind pipeline '{description.Name}' texture table");
// Push constants "survive pipeline changes within a pass" per the
// IGpuPassEncoder contract. GL uniforms are per-program state, so the
// GL backend must explicitly re-apply the last value to the newly
// bound program to honour that — Vulkan gets this for free from a
// shared pipeline layout.
if (_currentPushConstants is { } constants)
_device.PushConstants.Apply(p.GlProgram, in constants);
}
public void BindStorageBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes, uint sizeBytes)
{
ThrowIfClosed();
var b = RequireGlBuffer(buffer);
_gl.BindBufferRange(GLEnum.ShaderStorageBuffer, binding, b.GlName, (nint)offsetBytes, sizeBytes);
GLHelpers.ThrowOnResourceError(_gl, $"bind storage buffer '{buffer.Name}' at binding {binding}");
}
public void BindUniformBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes, uint sizeBytes)
{
ThrowIfClosed();
var b = RequireGlBuffer(buffer);
_gl.BindBufferRange(GLEnum.UniformBuffer, binding, b.GlName, (nint)offsetBytes, sizeBytes);
GLHelpers.ThrowOnResourceError(_gl, $"bind uniform buffer '{buffer.Name}' at binding {binding}");
}
public unsafe void BindVertexBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes)
{
ThrowIfClosed();
if (_currentPipeline is not { } pipeline)
throw new InvalidOperationException("BindPipeline must be called before BindVertexBuffer.");
var b = RequireGlBuffer(buffer);
_gl.BindBuffer(GLEnum.ArrayBuffer, b.GlName);
GpuVertexLayout layout = pipeline.Description.VertexLayout;
// Slice V6l: only the attributes this binding actually supplies. GL has
// no binding indirection of its own — glVertexAttribPointer records the
// currently bound ARRAY_BUFFER per attribute — so the binding index is
// resolved here, by filtering, rather than by the driver.
uint stride = layout.StrideOf(binding);
foreach (GpuVertexAttribute attribute in layout.Attributes)
{
if (attribute.Binding != binding)
continue;
GlVertexAttributeShape shape = GlEnumMapping.VertexShapeOf(attribute.Format);
nint attributeOffset = (nint)(offsetBytes + attribute.OffsetBytes);
if (shape.Integer)
{
// An integer shader input (uvec4) must come through the I-form.
// Supplying it via glVertexAttribPointer leaves the value undefined.
_gl.VertexAttribIPointer(
attribute.Location,
shape.ComponentCount,
(VertexAttribIType)shape.Type,
stride,
(void*)attributeOffset);
}
else
{
_gl.VertexAttribPointer(
attribute.Location,
shape.ComponentCount,
shape.Type,
shape.Normalized,
stride,
(void*)attributeOffset);
}
}
GLHelpers.ThrowOnResourceError(_gl, $"bind vertex buffer '{buffer.Name}'");
_gl.BindBuffer(GLEnum.ArrayBuffer, 0);
}
public void BindIndexBuffer(IGpuBuffer buffer, uint offsetBytes, GpuIndexType indexType)
{
ThrowIfClosed();
var b = RequireGlBuffer(buffer);
_currentIndexType = indexType;
_currentIndexBufferBaseOffset = offsetBytes;
_gl.BindBuffer(GLEnum.ElementArrayBuffer, b.GlName);
GLHelpers.ThrowOnResourceError(_gl, $"bind index buffer '{buffer.Name}'");
}
public void SetPushConstants(in GpuPushConstants constants)
{
ThrowIfClosed();
_currentPushConstants = constants;
if (_currentPipeline is { } pipeline)
_device.PushConstants.Apply(pipeline.GlProgram, in constants);
}
public void SetViewport(int x, int y, int width, int height)
{
ThrowIfClosed();
_gl.Viewport(x, y, (uint)width, (uint)height);
}
public void SetScissor(int x, int y, int width, int height)
{
ThrowIfClosed();
_gl.Enable(EnableCap.ScissorTest);
_gl.Scissor(x, y, (uint)width, (uint)height);
}
public void SetCullMode(GpuCullMode cullMode)
{
ThrowIfClosed();
_device.ApplyRenderState(_device.CurrentRenderState with { Cull = cullMode });
}
public void SetFrontFace(GpuFrontFace frontFace)
{
ThrowIfClosed();
_device.ApplyRenderState(_device.CurrentRenderState with { FrontFace = frontFace });
}
public void SetDepthWrite(bool enabled)
{
ThrowIfClosed();
_device.ApplyRenderState(_device.CurrentRenderState with { DepthWrite = enabled });
}
public void SetStencil(in GpuStencilState stencil)
{
ThrowIfClosed();
_device.ApplyRenderState(_device.CurrentRenderState with { Stencil = stencil });
}
public unsafe void DrawIndexed(uint indexCount, uint instanceCount, uint firstIndex, int vertexOffset, uint firstInstance)
{
ThrowIfClosed();
_device.FlushBeforeDraw(_frame.SlotIndex);
int indexSize = GlEnumMapping.IndexSizeBytesOf(_currentIndexType);
nint indexOffset = (nint)(_currentIndexBufferBaseOffset + firstIndex * (uint)indexSize);
_gl.DrawElementsInstancedBaseVertexBaseInstance(
GlEnumMapping.PrimitiveTypeOf(RequirePipeline().Description.Topology),
indexCount,
GlEnumMapping.DrawElementsTypeOf(_currentIndexType),
(void*)indexOffset,
instanceCount,
vertexOffset,
firstInstance);
GLHelpers.ThrowOnResourceError(_gl, "DrawIndexed");
}
public void Draw(uint vertexCount, uint instanceCount, uint firstVertex, uint firstInstance)
{
ThrowIfClosed();
_device.FlushBeforeDraw(_frame.SlotIndex);
_gl.DrawArraysInstancedBaseInstance(
(GLEnum)GlEnumMapping.PrimitiveTypeOf(RequirePipeline().Description.Topology),
(int)firstVertex,
vertexCount,
instanceCount,
firstInstance);
GLHelpers.ThrowOnResourceError(_gl, "Draw");
}
public unsafe void MultiDrawIndexedIndirect(IGpuBuffer commands, uint offsetBytes, uint drawCount, uint strideBytes)
{
ThrowIfClosed();
var indirect = RequireGlBuffer(commands);
_device.FlushBeforeDraw(_frame.SlotIndex);
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, indirect.GlName);
_gl.MultiDrawElementsIndirect(
GlEnumMapping.PrimitiveTypeOf(RequirePipeline().Description.Topology),
GlEnumMapping.DrawElementsTypeOf(_currentIndexType),
(void*)(nint)offsetBytes,
drawCount,
strideBytes);
GLHelpers.ThrowOnResourceError(_gl, "MultiDrawIndexedIndirect");
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0);
}
public IDisposable BeginTimerScope(string scopeName) => _device.TimerPool.BeginScope(scopeName);
public void Dispose()
{
if (_closed)
return;
_closed = true;
// GL has no store-op work to do here: GpuStoreOp.Resolve was already
// rejected at BeginPass (V1 targets are single-sampled), and
// Store/DontCare need no explicit action — the framebuffer's contents
// simply persist until the next pass rebinds a target.
//
// Restore whatever capability state was ambient before this pass
// opened (see the constructor's comment) so a still-raw-GL renderer
// running immediately after this pass sees exactly what it would have
// seen had this pass never bound a pipeline.
_ambientOnEntry.Restore(_ambientApi);
_frame.ClosePass(this);
}
private GlGpuPipeline RequirePipeline() =>
_currentPipeline ?? throw new InvalidOperationException("BindPipeline must be called before drawing.");
private static GlGpuBuffer RequireGlBuffer(IGpuBuffer buffer)
{
ArgumentNullException.ThrowIfNull(buffer);
if (buffer is not GlGpuBuffer glBuffer)
throw new ArgumentException("The GL backend can only bind GL buffers.", nameof(buffer));
return glBuffer;
}
private void ThrowIfClosed()
{
if (_closed)
throw new ObjectDisposedException(nameof(GlGpuPassEncoder));
}
}

View file

@ -1,97 +0,0 @@
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering.Gpu.Gl;
/// <summary>
/// Compiles <see cref="GpuPipelineDescription.Shaders"/> through the existing
/// <see cref="ShaderProgramConstruction"/> (the same compiler every other GL
/// shader in the codebase uses — this is not a second compiler) and owns one
/// VAO shaped by <see cref="GpuPipelineDescription.VertexLayout"/>.
///
/// The VAO only records which attribute locations are enabled and their
/// component shape; it does NOT bind a vertex buffer at creation time. Vertex
/// attribute pointers are re-issued by <see cref="GlGpuPassEncoder.BindVertexBuffer"/>
/// every time a buffer/offset is bound, because a ring allocation's offset
/// changes every frame — GL has no notion of "rebase this VAO's buffer at a
/// new offset" independent of re-issuing <c>glVertexAttribPointer</c>.
/// </summary>
internal sealed class GlGpuPipeline : IGpuPipeline
{
private readonly GL _gl;
private readonly IGpuResourceRetirementQueue _retirement;
private uint _program;
private uint _vertexArray;
public GlGpuPipeline(GL gl, IGpuResourceRetirementQueue retirement, GpuPipelineDescription description, string vertexSource, string fragmentSource)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_retirement = retirement ?? throw new ArgumentNullException(nameof(retirement));
Description = description ?? throw new ArgumentNullException(nameof(description));
_program = ShaderProgramConstruction.Build(new GlShaderProgramBuildApi(_gl), vertexSource, fragmentSource);
try
{
_vertexArray = GlResourceCommand.CreateName(
_gl,
$"pipeline '{description.Name}' VAO",
_gl.GenVertexArray,
_gl.DeleteVertexArray);
_gl.BindVertexArray(_vertexArray);
GpuVertexLayout layout = description.VertexLayout;
foreach (GpuVertexAttribute attribute in layout.Attributes)
{
_gl.EnableVertexAttribArray(attribute.Location);
// Slice V6l: the divisor is VAO state and survives every later
// glVertexAttribPointer, so it belongs here with the enables
// rather than in the per-frame rebind. Zero is the GL default and
// is restated explicitly — a VAO name can be recycled by the
// driver, and inheriting a stale divisor draws one instance's
// data across every vertex.
_gl.VertexAttribDivisor(
attribute.Location,
layout.InputRateOf(attribute.Binding) == GpuVertexInputRate.Instance ? 1u : 0u);
}
GLHelpers.ThrowOnResourceError(_gl, $"configure pipeline '{description.Name}' VAO");
_gl.BindVertexArray(0);
}
catch
{
_gl.DeleteProgram(_program);
_program = 0;
throw;
}
}
public GpuPipelineDescription Description { get; }
internal uint GlProgram => _program;
internal uint GlVertexArray => _vertexArray;
public void Dispose()
{
uint program = _program;
uint vertexArray = _vertexArray;
if (program == 0 && vertexArray == 0)
return;
_program = 0;
_vertexArray = 0;
GL gl = _gl;
string label = Description.Name;
_retirement.Retire(() =>
{
if (vertexArray != 0)
{
gl.DeleteVertexArray(vertexArray);
GLHelpers.ThrowOnResourceError(gl, $"delete pipeline '{label}' VAO");
}
if (program != 0)
{
gl.DeleteProgram(program);
GLHelpers.ThrowOnResourceError(gl, $"delete pipeline '{label}' program");
}
});
}
}

View file

@ -1,81 +0,0 @@
using System.Numerics;
using AcDream.App.Rendering.Wb;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering.Gpu.Gl;
/// <summary>
/// Applies a <see cref="GpuPushConstants"/> value to the currently bound GL
/// program by uniform name (<see cref="GlPushConstantUniformNames"/>),
/// caching each program's resolved locations so the name lookup only happens
/// once per program. A location of -1 (the program does not declare that
/// uniform) is silently skipped, exactly as the contract's XML docs specify.
/// </summary>
internal sealed class GlGpuPushConstantBinder
{
private readonly GL _gl;
private readonly Dictionary<uint, ProgramLocations> _locationsByProgram = new();
public GlGpuPushConstantBinder(GL gl) => _gl = gl ?? throw new ArgumentNullException(nameof(gl));
public unsafe void Apply(uint program, in GpuPushConstants constants)
{
ProgramLocations locations = GetOrResolveLocations(program);
if (locations.ViewProjection >= 0)
{
Matrix4x4 m = constants.ViewProjection;
_gl.UniformMatrix4(locations.ViewProjection, 1, false, (float*)&m);
}
if (locations.DrawIdOffset >= 0)
_gl.Uniform1(locations.DrawIdOffset, constants.DrawIdOffset);
if (locations.LightingMode >= 0)
_gl.Uniform1(locations.LightingMode, constants.LightingMode);
if (locations.RenderPass >= 0)
_gl.Uniform1(locations.RenderPass, constants.RenderPass);
if (locations.LightDebug >= 0)
_gl.Uniform1(locations.LightDebug, constants.LightDebug);
if (locations.TextureIndexA >= 0)
_gl.Uniform1(locations.TextureIndexA, constants.TextureIndexA);
if (locations.TextureIndexB >= 0)
_gl.Uniform1(locations.TextureIndexB, constants.TextureIndexB);
if (locations.ParamA >= 0)
_gl.Uniform1(locations.ParamA, constants.ParamA);
if (locations.ParamB >= 0)
_gl.Uniform1(locations.ParamB, constants.ParamB);
GLHelpers.ThrowOnResourceError(_gl, $"apply push constants to program {program}");
}
/// <summary>Drops cached locations for a deleted program. Call before its GL name is reused.</summary>
public void Forget(uint program) => _locationsByProgram.Remove(program);
private ProgramLocations GetOrResolveLocations(uint program)
{
if (_locationsByProgram.TryGetValue(program, out ProgramLocations existing))
return existing;
ProgramLocations locations = new(
_gl.GetUniformLocation(program, GlPushConstantUniformNames.ViewProjection),
_gl.GetUniformLocation(program, GlPushConstantUniformNames.DrawIdOffset),
_gl.GetUniformLocation(program, GlPushConstantUniformNames.LightingMode),
_gl.GetUniformLocation(program, GlPushConstantUniformNames.RenderPass),
_gl.GetUniformLocation(program, GlPushConstantUniformNames.LightDebug),
_gl.GetUniformLocation(program, GlPushConstantUniformNames.TextureIndexA),
_gl.GetUniformLocation(program, GlPushConstantUniformNames.TextureIndexB),
_gl.GetUniformLocation(program, GlPushConstantUniformNames.ParamA),
_gl.GetUniformLocation(program, GlPushConstantUniformNames.ParamB));
_locationsByProgram[program] = locations;
return locations;
}
private readonly record struct ProgramLocations(
int ViewProjection,
int DrawIdOffset,
int LightingMode,
int RenderPass,
int LightDebug,
int TextureIndexA,
int TextureIndexB,
int ParamA,
int ParamB);
}

View file

@ -1,121 +0,0 @@
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering.Gpu.Gl;
/// <summary>
/// An offscreen colour(+depth) FBO. The colour attachment is a
/// <see cref="GlGpuTexture"/> so it can be registered into the texture table
/// after the pass; the depth/stencil attachment (when requested) is a plain
/// renderbuffer, mirroring <c>ManagedGLFramebuffer</c>'s approach — nothing
/// ever samples depth for the targets this slice's contract describes
/// (paperdoll, creature appraisal, portal masking).
/// </summary>
internal sealed class GlGpuRenderTarget : IGpuRenderTarget
{
private readonly GL _gl;
private readonly IGpuResourceRetirementQueue _retirement;
private uint _framebuffer;
private uint _depthStencilRenderbuffer;
private GlGpuTexture? _color;
public GlGpuRenderTarget(GL gl, IGpuResourceRetirementQueue retirement, GpuRenderTargetDescription description)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_retirement = retirement ?? throw new ArgumentNullException(nameof(retirement));
Description = description;
if (description.SampleCount != 1)
{
throw new NotSupportedException(
"GL render targets are single-sampled in Campaign V slice V1; " +
"MSAA offscreen targets are not part of this contract.");
}
_color = new GlGpuTexture(
_gl,
_retirement,
new GpuTextureDescription(
$"{description.Name}-color",
GpuTextureKind.Texture2D,
description.ColorFormat,
description.Width,
description.Height,
LayerCount: 1,
MipLevelCount: 1));
_framebuffer = GlResourceCommand.CreateName(_gl, $"framebuffer '{description.Name}'", _gl.GenFramebuffer, _gl.DeleteFramebuffer);
_gl.BindFramebuffer(GLEnum.Framebuffer, _framebuffer);
_gl.FramebufferTexture2D(
GLEnum.Framebuffer,
GLEnum.ColorAttachment0,
GLEnum.Texture2D,
_color.GlName,
0);
if (description.DepthFormat is not null)
{
_depthStencilRenderbuffer = GlResourceCommand.CreateName(
_gl,
$"depth renderbuffer '{description.Name}'",
_gl.GenRenderbuffer,
_gl.DeleteRenderbuffer);
_gl.BindRenderbuffer(GLEnum.Renderbuffer, _depthStencilRenderbuffer);
_gl.RenderbufferStorage(GLEnum.Renderbuffer, GLEnum.Depth24Stencil8, (uint)description.Width, (uint)description.Height);
_gl.FramebufferRenderbuffer(
GLEnum.Framebuffer,
GLEnum.DepthStencilAttachment,
GLEnum.Renderbuffer,
_depthStencilRenderbuffer);
}
GLEnum status = _gl.CheckFramebufferStatus(GLEnum.Framebuffer);
_gl.BindFramebuffer(GLEnum.Framebuffer, 0);
if (status != GLEnum.FramebufferComplete)
{
// Roll back the two names this constructor already owns before
// surfacing the failure — nothing has been published yet.
_gl.DeleteFramebuffer(_framebuffer);
if (_depthStencilRenderbuffer != 0)
_gl.DeleteRenderbuffer(_depthStencilRenderbuffer);
_color.Dispose();
throw new InvalidOperationException(
$"Render target '{description.Name}' framebuffer is incomplete: {status}.");
}
}
public GpuRenderTargetDescription Description { get; }
public IGpuTexture ColorTexture => _color ?? throw new ObjectDisposedException(Description.Name);
internal uint GlFramebufferName => _framebuffer;
public void Dispose()
{
uint framebuffer = _framebuffer;
uint renderbuffer = _depthStencilRenderbuffer;
GlGpuTexture? color = _color;
if (framebuffer == 0 && color is null)
return;
_framebuffer = 0;
_depthStencilRenderbuffer = 0;
_color = null;
GL gl = _gl;
string label = Description.Name;
_retirement.Retire(() =>
{
if (framebuffer != 0)
{
gl.DeleteFramebuffer(framebuffer);
GLHelpers.ThrowOnResourceError(gl, $"delete framebuffer '{label}'");
}
if (renderbuffer != 0)
{
gl.DeleteRenderbuffer(renderbuffer);
GLHelpers.ThrowOnResourceError(gl, $"delete depth renderbuffer '{label}'");
}
});
color?.Dispose();
}
}

View file

@ -1,61 +0,0 @@
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering.Gpu.Gl;
/// <summary>
/// One GL sampler object. <see cref="GlGpuDevice.CreateSampler"/>
/// de-duplicates by <see cref="GpuSamplerDescription"/> value, so a given
/// wrap/filter combination is only ever backed by one physical sampler name —
/// the same pattern <c>SamplerCache</c> already uses for its two fixed
/// samplers, generalized to the full description space the RHI exposes.
/// </summary>
internal sealed class GlGpuSampler : IGpuSampler
{
private const uint GlTextureMaxAnisotropy = 0x84FE;
private readonly GL _gl;
private readonly IGpuResourceRetirementQueue _retirement;
private uint _name;
public GlGpuSampler(GL gl, IGpuResourceRetirementQueue retirement, GpuSamplerDescription description)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_retirement = retirement ?? throw new ArgumentNullException(nameof(retirement));
Description = description;
_name = GlResourceCommand.CreateName(_gl, "sampler", _gl.GenSampler, _gl.DeleteSampler);
TextureMinFilter minFilter = GlEnumMapping.MinFilterOf(description.MinFilter, description.MipFilter);
TextureMagFilter magFilter = GlEnumMapping.MagFilterOf(description.MagFilter);
TextureWrapMode wrapU = GlEnumMapping.WrapModeOf(description.AddressU);
TextureWrapMode wrapV = GlEnumMapping.WrapModeOf(description.AddressV);
_gl.SamplerParameter(_name, SamplerParameterI.MinFilter, (int)minFilter);
_gl.SamplerParameter(_name, SamplerParameterI.MagFilter, (int)magFilter);
_gl.SamplerParameter(_name, SamplerParameterI.WrapS, (int)wrapU);
_gl.SamplerParameter(_name, SamplerParameterI.WrapT, (int)wrapV);
if (description.MaxAnisotropy > 1f)
_gl.SamplerParameter(_name, (SamplerParameterF)GlTextureMaxAnisotropy, description.MaxAnisotropy);
GLHelpers.ThrowOnResourceError(_gl, $"configure sampler {_name}");
}
public GpuSamplerDescription Description { get; }
internal uint GlName => _name;
public void Dispose()
{
uint name = _name;
if (name == 0)
return;
_name = 0;
GL gl = _gl;
_retirement.Retire(() =>
{
gl.DeleteSampler(name);
GLHelpers.ThrowOnResourceError(gl, $"delete sampler {name}");
});
}
}

View file

@ -1,202 +0,0 @@
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering.Gpu.Gl;
/// <summary>
/// A GL texture (2D or 2D array) allocated with immutable storage
/// (<c>glTexStorage2D/3D</c>) so every mip level is defined the moment the
/// object is created, regardless of upload order — mirroring
/// <c>ManagedGLTextureArray</c>'s allocation strategy without inheriting its
/// Chorizite/<see cref="OpenGLGraphicsDevice"/> coupling.
///
/// The texture's own min/mag filter is set at creation (not left at GL
/// defaults) purely so the texture is "complete" for sampling the instant it
/// exists — completeness is judged from the texture object's own filter
/// state, independent of whatever <see cref="IGpuSampler"/> a draw later
/// binds. The bound sampler object overrides actual filtering at draw time
/// (same override rule <c>SamplerCache</c> already documents), so this
/// default never affects the rendered image.
/// </summary>
internal sealed class GlGpuTexture : IGpuTexture
{
private readonly GL _gl;
private readonly IGpuResourceRetirementQueue _retirement;
private readonly GlTextureFormatInfo _formatInfo;
private readonly GLEnum _target;
private uint _name;
public GlGpuTexture(GL gl, IGpuResourceRetirementQueue retirement, GpuTextureDescription description)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_retirement = retirement ?? throw new ArgumentNullException(nameof(retirement));
Name = description.Name;
Kind = description.Kind;
Format = description.Format;
Width = description.Width;
Height = description.Height;
LayerCount = description.LayerCount;
MipLevelCount = description.MipLevelCount;
_formatInfo = GlGpuTextureFormatMapping.Resolve(Format);
_target = Kind == GpuTextureKind.Texture2D ? GLEnum.Texture2D : GLEnum.Texture2DArray;
_name = GlResourceCommand.CreateName(_gl, $"texture '{Name}'", _gl.GenTexture, _gl.DeleteTexture);
_gl.BindTexture(_target, _name);
if (Kind == GpuTextureKind.Texture2D)
{
_gl.TexStorage2D(_target, (uint)MipLevelCount, _formatInfo.SizedInternalFormat, (uint)Width, (uint)Height);
}
else
{
_gl.TexStorage3D(
_target,
(uint)MipLevelCount,
_formatInfo.SizedInternalFormat,
(uint)Width,
(uint)Height,
(uint)LayerCount);
}
GLHelpers.ThrowOnResourceError(
_gl,
$"allocate texture '{Name}' {Format} {Width}x{Height}x{LayerCount} ({MipLevelCount} mips)");
TextureMinFilter minFilter = MipLevelCount > 1 ? TextureMinFilter.LinearMipmapLinear : TextureMinFilter.Linear;
_gl.TexParameter(_target, TextureParameterName.TextureMinFilter, (int)minFilter);
_gl.TexParameter(_target, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
GLHelpers.ThrowOnResourceError(_gl, $"set default filter for texture '{Name}'");
_gl.BindTexture(_target, 0);
}
public string Name { get; }
public GpuTextureKind Kind { get; }
public GpuTextureFormat Format { get; }
public int Width { get; }
public int Height { get; }
public int LayerCount { get; }
public int MipLevelCount { get; }
/// <summary>The physical GL texture name. Used by <see cref="GlGpuDevice.RegisterTexture"/> to obtain a bindless handle.</summary>
internal uint GlName => _name;
internal GLEnum Target => _target;
public void Upload(int mipLevel, int layer, ReadOnlySpan<byte> data)
{
ThrowIfDisposed();
if (Format == GpuTextureFormat.Depth24Stencil8)
{
throw new NotSupportedException(
"Depth24Stencil8 textures are attachment-only; they are never uploaded from the CPU.");
}
if ((uint)mipLevel >= (uint)MipLevelCount)
throw new ArgumentOutOfRangeException(nameof(mipLevel));
if (Kind == GpuTextureKind.Texture2D && layer != 0)
throw new ArgumentOutOfRangeException(nameof(layer), "A 2D texture only has layer 0.");
if (Kind == GpuTextureKind.Texture2DArray && (uint)layer >= (uint)LayerCount)
throw new ArgumentOutOfRangeException(nameof(layer));
int mipWidth = Math.Max(1, Width >> mipLevel);
int mipHeight = Math.Max(1, Height >> mipLevel);
long expected = _formatInfo.LayerByteCount(mipWidth, mipHeight);
if (data.Length != expected)
{
throw new ArgumentException(
$"Upload for texture '{Name}' mip {mipLevel} has {data.Length} bytes; expected {expected} " +
$"for {Format} {mipWidth}x{mipHeight}.",
nameof(data));
}
_gl.BindTexture(_target, _name);
_gl.PixelStore(PixelStoreParameter.UnpackAlignment, 1);
unsafe
{
fixed (byte* pointer = data)
{
if (Kind == GpuTextureKind.Texture2D)
UploadTexture2D(mipLevel, mipWidth, mipHeight, data.Length, pointer);
else
UploadTexture2DArray(mipLevel, layer, mipWidth, mipHeight, data.Length, pointer);
}
}
GLHelpers.ThrowOnResourceError(_gl, $"upload texture '{Name}' mip {mipLevel} layer {layer}");
_gl.BindTexture(_target, 0);
}
private unsafe void UploadTexture2D(int mipLevel, int mipWidth, int mipHeight, int byteCount, byte* pointer)
{
if (_formatInfo.IsCompressed)
{
_gl.CompressedTexSubImage2D(
_target, mipLevel, 0, 0, (uint)mipWidth, (uint)mipHeight,
(InternalFormat)_formatInfo.SizedInternalFormat, (uint)byteCount, pointer);
}
else
{
_gl.TexSubImage2D(
_target, mipLevel, 0, 0, (uint)mipWidth, (uint)mipHeight,
_formatInfo.UploadPixelFormat, _formatInfo.UploadPixelType, pointer);
}
}
private unsafe void UploadTexture2DArray(int mipLevel, int layer, int mipWidth, int mipHeight, int byteCount, byte* pointer)
{
if (_formatInfo.IsCompressed)
{
_gl.CompressedTexSubImage3D(
_target, mipLevel, 0, 0, layer, (uint)mipWidth, (uint)mipHeight, 1,
(InternalFormat)_formatInfo.SizedInternalFormat, (uint)byteCount, pointer);
}
else
{
_gl.TexSubImage3D(
_target, mipLevel, 0, 0, layer, (uint)mipWidth, (uint)mipHeight, 1,
_formatInfo.UploadPixelFormat, _formatInfo.UploadPixelType, pointer);
}
}
public void GenerateMipChain()
{
ThrowIfDisposed();
if (MipLevelCount <= 1)
return;
// Matches ManagedGLTextureArray.ProcessDirtyUpdatesInternal: GL's
// driver-defined compressed-mip regeneration is the one behaviour this
// migration deliberately does not carry into the RHI contract (the
// Vulkan backend cannot blit BC images at all and requires a CPU-built
// chain instead). Callers that need BC mips must supply every level
// through Upload directly; this is a documented no-op for them on GL,
// not a silent gap, since the GL renderer path today already skips
// glGenerateMipmap for compressed arrays.
if (_formatInfo.IsCompressed)
return;
_gl.BindTexture(_target, _name);
_gl.GenerateMipmap(_target);
GLHelpers.ThrowOnResourceError(_gl, $"generate mip chain for texture '{Name}'");
_gl.BindTexture(_target, 0);
}
public void Dispose()
{
uint name = _name;
if (name == 0)
return;
_name = 0;
GL gl = _gl;
string label = Name;
_retirement.Retire(() =>
{
gl.DeleteTexture(name);
GLHelpers.ThrowOnResourceError(gl, $"delete texture '{label}' ({name})");
});
}
private void ThrowIfDisposed()
{
if (_name == 0)
throw new ObjectDisposedException(Name);
}
}

View file

@ -1,56 +0,0 @@
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering.Gpu.Gl;
/// <summary>
/// GL format triple for a <see cref="GpuTextureFormat"/>: the sized internal
/// format used at allocation (<c>glTexStorage2D/3D</c>), the upload
/// format/type for uncompressed uploads, and whether uploads go through
/// <c>glCompressedTexSubImage2D/3D</c> instead.
///
/// <see cref="GpuTextureFormat"/> is acdream's own enum, not the Chorizite
/// <c>TextureFormat</c> the existing <c>TextureFormatExtensions</c> targets,
/// so this is the "otherwise add a private mapper" fallback the campaign spec
/// calls for rather than an extension of that existing type. Pure data —
/// safe to unit test without a live GL context, since Silk's GL enums are
/// plain value types.
/// </summary>
internal readonly record struct GlTextureFormatInfo(
SizedInternalFormat SizedInternalFormat,
PixelFormat UploadPixelFormat,
PixelType UploadPixelType,
bool IsCompressed,
int BlockOrTexelBytes,
int BlockDimension)
{
/// <summary>Bytes required for one full, uncompressed mip layer at the given dimensions, or one compressed layer rounded up to whole blocks.</summary>
public long LayerByteCount(int width, int height)
{
if (!IsCompressed)
return (long)width * height * BlockOrTexelBytes;
int blocksWide = (width + BlockDimension - 1) / BlockDimension;
int blocksHigh = (height + BlockDimension - 1) / BlockDimension;
return (long)blocksWide * blocksHigh * BlockOrTexelBytes;
}
}
internal static class GlGpuTextureFormatMapping
{
public static GlTextureFormatInfo Resolve(GpuTextureFormat format) => format switch
{
GpuTextureFormat.Rgba8Unorm or GpuTextureFormat.Rgba8UnormRenderTarget =>
new GlTextureFormatInfo(SizedInternalFormat.Rgba8, PixelFormat.Rgba, PixelType.UnsignedByte, false, 4, 1),
GpuTextureFormat.R8Unorm =>
new GlTextureFormatInfo(SizedInternalFormat.R8, PixelFormat.Red, PixelType.UnsignedByte, false, 1, 1),
GpuTextureFormat.Bc1Unorm =>
new GlTextureFormatInfo(SizedInternalFormat.CompressedRgbaS3TCDxt1Ext, PixelFormat.Rgba, PixelType.UnsignedByte, true, 8, 4),
GpuTextureFormat.Bc2Unorm =>
new GlTextureFormatInfo(SizedInternalFormat.CompressedRgbaS3TCDxt3Ext, PixelFormat.Rgba, PixelType.UnsignedByte, true, 16, 4),
GpuTextureFormat.Bc3Unorm =>
new GlTextureFormatInfo(SizedInternalFormat.CompressedRgbaS3TCDxt5Ext, PixelFormat.Rgba, PixelType.UnsignedByte, true, 16, 4),
GpuTextureFormat.Depth24Stencil8 =>
new GlTextureFormatInfo(SizedInternalFormat.Depth24Stencil8, PixelFormat.DepthStencil, PixelType.UnsignedInt248, false, 4, 1),
_ => throw new NotSupportedException($"No GL format mapping for {format}."),
};
}

View file

@ -1,186 +0,0 @@
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering.Gpu.Gl;
/// <summary>
/// Seam over the four GL calls a <c>TimeElapsed</c> timer scope needs, mirroring
/// <c>IGpuFenceApi</c>'s role for <see cref="GpuFrameFlightController"/>: it lets
/// <see cref="GlGpuTimerPool"/>'s double-buffering and result-promotion logic run
/// under a unit test with no live GL context.
/// </summary>
internal interface IGlTimerQueryApi
{
uint CreateQuery();
void DeleteQuery(uint query);
void Begin(uint query);
void End();
/// <summary>Non-blocking: false when the result for <paramref name="query"/> is not yet available (or the query was never begun).</summary>
bool TryGetResult(uint query, out double milliseconds);
}
internal sealed class SilkGlTimerQueryApi(GL gl) : IGlTimerQueryApi
{
private readonly GL _gl = gl ?? throw new ArgumentNullException(nameof(gl));
public uint CreateQuery() => _gl.GenQuery();
public void DeleteQuery(uint query) => _gl.DeleteQuery(query);
public void Begin(uint query) => _gl.BeginQuery(QueryTarget.TimeElapsed, query);
public void End() => _gl.EndQuery(QueryTarget.TimeElapsed);
public bool TryGetResult(uint query, out double milliseconds)
{
_gl.GetQueryObject(query, QueryObjectParameterName.ResultAvailable, out int available);
if (available == 0)
{
milliseconds = 0;
return false;
}
_gl.GetQueryObject(query, QueryObjectParameterName.Result, out ulong elapsedNanoseconds);
milliseconds = elapsedNanoseconds / 1_000_000d;
return true;
}
}
/// <summary>
/// <see cref="IGpuTimerPool"/> backed by <c>TimeElapsed</c> queries. Core GL
/// only allows one <c>GL_TIME_ELAPSED</c> query active at a time (the
/// restriction is per target, not per query object), so scopes must not
/// nest — <see cref="BeginScope"/> throws if a previous scope in the same
/// pass hasn't been disposed yet, exactly as <c>WbDrawDispatcher</c> already
/// requires for its own opaque/transparent query pair.
///
/// Each distinct scope name gets its own double-buffered pair of query
/// objects (per the campaign spec: "double-buffered so results are read from
/// a retired frame and never block"). A scope's second call reads back the
/// FIRST call's result non-blockingly — by the time a scope name repeats,
/// at least one full frame has usually retired, so the result is normally
/// ready; if it is not, the previous value is kept until it is.
/// </summary>
internal sealed class GlGpuTimerPool : IGpuTimerPool
{
private const int BufferDepth = 2;
private sealed class ScopeState
{
public readonly uint[] Queries = new uint[BufferDepth];
public readonly bool[] Began = new bool[BufferDepth];
public int NextSlot;
public double LastResolvedMilliseconds;
public bool HasResolvedValue;
}
private readonly IGlTimerQueryApi _api;
private readonly Dictionary<string, ScopeState> _scopes = new(StringComparer.Ordinal);
private string? _activeScopeName;
public GlGpuTimerPool(IGlTimerQueryApi api, bool isSupported)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
IsSupported = isSupported;
}
public bool IsSupported { get; }
/// <summary>
/// Begins (or continues) the named scope. Returns a disposable that ends
/// the query; dispose it before beginning another scope in the same pass.
/// </summary>
public IDisposable BeginScope(string scopeName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(scopeName);
if (!IsSupported)
return NullTimerScope.Instance;
if (_activeScopeName is not null)
{
throw new InvalidOperationException(
$"GPU timer scope '{_activeScopeName}' is still active; TimeElapsed queries " +
"cannot nest. Dispose the previous scope before beginning another.");
}
if (!_scopes.TryGetValue(scopeName, out ScopeState? state))
{
state = new ScopeState();
for (int i = 0; i < BufferDepth; i++)
state.Queries[i] = _api.CreateQuery();
_scopes.Add(scopeName, state);
}
int slot = state.NextSlot;
state.NextSlot = (slot + 1) % BufferDepth;
// Poll the OTHER slot's query — the one begun on the previous call to
// this scope name — before beginning this one. That is what makes a
// scope's Nth call read back the (N-1)th call's result: with only two
// slots, "the previous slot" and "the other slot" are the same index,
// and by now it has usually had at least one frame to retire.
int previousSlot = (slot + BufferDepth - 1) % BufferDepth;
if (state.Began[previousSlot] && _api.TryGetResult(state.Queries[previousSlot], out double milliseconds))
{
state.LastResolvedMilliseconds = milliseconds;
state.HasResolvedValue = true;
}
_api.Begin(state.Queries[slot]);
state.Began[slot] = true;
_activeScopeName = scopeName;
return new ActiveTimerScope(this, scopeName);
}
public bool TryResolve(string scopeName, out double milliseconds)
{
if (_scopes.TryGetValue(scopeName, out ScopeState? state) && state.HasResolvedValue)
{
milliseconds = state.LastResolvedMilliseconds;
return true;
}
milliseconds = 0;
return false;
}
private void EndScope(string scopeName)
{
if (_activeScopeName != scopeName)
return;
_api.End();
_activeScopeName = null;
}
internal void DisposeQueries()
{
foreach (ScopeState state in _scopes.Values)
{
foreach (uint query in state.Queries)
_api.DeleteQuery(query);
}
_scopes.Clear();
}
private sealed class ActiveTimerScope(GlGpuTimerPool pool, string scopeName) : IDisposable
{
private bool _disposed;
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
pool.EndScope(scopeName);
}
}
private sealed class NullTimerScope : IDisposable
{
public static NullTimerScope Instance { get; } = new();
public void Dispose()
{
}
}
}

View file

@ -1,72 +0,0 @@
using System.Reflection;
namespace AcDream.App.Rendering.Gpu.Gl;
/// <summary>
/// The single source of truth mapping each <see cref="GpuPushConstants"/>
/// field to the GLSL uniform name the GL backend binds it to — the names
/// documented on the fields themselves. <see cref="GlGpuPushConstantBinder"/>
/// reads these constants (not string literals of its own) when it calls
/// <c>GL.GetUniformLocation</c>, and <see cref="AssertMapsEveryField"/> lets a
/// test prove the table cannot silently drop a field that a later slice adds
/// to the shared struct.
/// </summary>
internal static class GlPushConstantUniformNames
{
public const string ViewProjection = "uViewProjection";
public const string DrawIdOffset = "uDrawIDOffset";
public const string LightingMode = "uLightingMode";
public const string RenderPass = "uRenderPass";
public const string LightDebug = "uLightDebug";
public const string TextureIndexA = "uTextureIndexA";
public const string TextureIndexB = "uTextureIndexB";
public const string ParamA = "uParamA";
public const string ParamB = "uParamB";
/// <summary>
/// Field name (as declared on <see cref="GpuPushConstants"/>) to GLSL
/// uniform name, built from the same constants the binder applies with —
/// so the binder and this completeness table can never disagree.
/// </summary>
public static IReadOnlyDictionary<string, string> ByFieldName { get; } =
new Dictionary<string, string>
{
[nameof(GpuPushConstants.ViewProjection)] = ViewProjection,
[nameof(GpuPushConstants.DrawIdOffset)] = DrawIdOffset,
[nameof(GpuPushConstants.LightingMode)] = LightingMode,
[nameof(GpuPushConstants.RenderPass)] = RenderPass,
[nameof(GpuPushConstants.LightDebug)] = LightDebug,
[nameof(GpuPushConstants.TextureIndexA)] = TextureIndexA,
[nameof(GpuPushConstants.TextureIndexB)] = TextureIndexB,
[nameof(GpuPushConstants.ParamA)] = ParamA,
[nameof(GpuPushConstants.ParamB)] = ParamB,
};
/// <summary>
/// Throws if <see cref="GpuPushConstants"/> declares a public instance
/// field this table does not map — the drift guard the campaign spec asks
/// for. Called from a unit test, not from production code.
/// </summary>
internal static void AssertMapsEveryField()
{
FieldInfo[] fields = typeof(GpuPushConstants).GetFields(
BindingFlags.Public | BindingFlags.Instance);
foreach (FieldInfo field in fields)
{
if (!ByFieldName.ContainsKey(field.Name))
{
throw new InvalidOperationException(
$"GpuPushConstants.{field.Name} has no mapped GLSL uniform name in " +
$"{nameof(GlPushConstantUniformNames)}.");
}
}
if (ByFieldName.Count != fields.Length)
{
throw new InvalidOperationException(
$"{nameof(GlPushConstantUniformNames)} maps {ByFieldName.Count} names but " +
$"{nameof(GpuPushConstants)} declares {fields.Length} fields — a stale entry " +
"survives a field rename or removal.");
}
}
}

View file

@ -1,84 +0,0 @@
namespace AcDream.App.Rendering.Gpu.Gl;
/// <summary>
/// Everything <see cref="GlGpuPipeline"/> bakes plus the handful of fields
/// core Vulkan 1.3 (and this backend) makes dynamic per draw.
/// <see cref="Program"/> is the GL program name, included so a pipeline
/// switch is itself a tracked dimension.
/// </summary>
internal readonly record struct GlRenderStateSnapshot(
uint Program,
GpuBlendMode Blend,
bool DepthTest,
bool DepthWrite,
GpuCompareOp DepthCompare,
GpuCullMode Cull,
GpuFrontFace FrontFace,
bool AlphaToCoverage,
bool ColorWrite,
bool StencilTest,
GpuStencilState Stencil);
/// <summary>Which GL state calls are needed to move from the previous snapshot to the new one.</summary>
internal readonly record struct GlRenderStateChanges(
bool Program,
bool Blend,
bool DepthTest,
bool DepthWrite,
bool DepthCompare,
bool Cull,
bool FrontFace,
bool AlphaToCoverage,
bool ColorWrite,
bool StencilTest,
bool Stencil)
{
public bool AnyChange =>
Program || Blend || DepthTest || DepthWrite || DepthCompare
|| Cull || FrontFace || AlphaToCoverage || ColorWrite
|| StencilTest || Stencil;
/// <summary>Every dimension reported changed — used for the first apply after a reset.</summary>
internal static GlRenderStateChanges All { get; } =
new(true, true, true, true, true, true, true, true, true, true, true);
}
/// <summary>
/// Pure GL-free state cache: given the previously applied
/// <see cref="GlRenderStateSnapshot"/> and a newly desired one, reports which
/// dimensions actually changed so <see cref="GlGpuPassEncoder"/> only issues
/// the GL calls that matter. Starts with no baseline, so the very first
/// <see cref="Apply"/> call always reports every dimension changed — "when in
/// doubt, set the state" rather than risk stale driver state from before this
/// cache existed (e.g. from a previous pass, or default GL state that may not
/// match a pipeline's baked defaults).
/// </summary>
internal sealed class GlRenderStateCache
{
private GlRenderStateSnapshot? _last;
public GlRenderStateChanges Apply(GlRenderStateSnapshot desired)
{
GlRenderStateSnapshot? previous = _last;
_last = desired;
if (previous is not { } p)
return GlRenderStateChanges.All;
return new GlRenderStateChanges(
p.Program != desired.Program,
p.Blend != desired.Blend,
p.DepthTest != desired.DepthTest,
p.DepthWrite != desired.DepthWrite,
p.DepthCompare != desired.DepthCompare,
p.Cull != desired.Cull,
p.FrontFace != desired.FrontFace,
p.AlphaToCoverage != desired.AlphaToCoverage,
p.ColorWrite != desired.ColorWrite,
p.StencilTest != desired.StencilTest,
p.Stencil != desired.Stencil);
}
/// <summary>Discards the cached baseline — the next <see cref="Apply"/> reports every dimension changed.</summary>
public void Reset() => _last = null;
}

View file

@ -1,140 +0,0 @@
namespace AcDream.App.Rendering.Gpu.Gl;
/// <summary>
/// Pure bookkeeping for one flight slot's upload ring: an allocation cursor
/// that only grows across a frame, a "dirty" watermark tracking the byte range
/// written since the last flush, and the flushed high-water mark that makes the
/// ring's write path safe to issue unsynchronized.
///
/// This is deliberately GL-free so it can be unit tested without a live
/// context. <see cref="GlGpuFrame"/>'s device owns one instance per flight slot
/// and pairs it with a managed staging <c>byte[]</c> and a real GL buffer; the
/// staging array receives every <see cref="GpuRingAllocation.Data"/> write, and
/// immediately before each <c>Draw</c>/<c>DrawIndexed</c>/
/// <c>MultiDrawIndexedIndirect</c> the device writes exactly the dirty range
/// into the GL buffer through
/// <see cref="GlGpuBuffer.WriteRangeUnsynchronized"/> and calls
/// <see cref="TakeDirtyRange"/> to reset the watermark. The allocation cursor
/// itself only resets at <see cref="Reset"/> (once per <c>BeginFrame</c>) —
/// writes made after a flush simply extend the dirty range again, to be picked
/// up by the next flush. This is what makes "flush before every draw, not at
/// bind time" correct: a renderer that writes after binding still gets uploaded
/// before its draw call runs.
///
/// <para><b>The forward-only invariant is load-bearing, not incidental.</b> The
/// device's per-flush write is mapped with <c>GL_MAP_UNSYNCHRONIZED_BIT</c>,
/// which means the driver inserts no wait and the caller is asserting that no
/// submitted-and-unfinished draw reads the range. Within a frame that holds
/// because <see cref="Allocate"/> only ever hands out bytes at or above the
/// cursor, so each flush covers a range strictly above every range already
/// flushed. Rather than leave that as an emergent property of the arithmetic,
/// <see cref="MarkDirty"/> refuses a write below <see cref="FlushedEndBytes"/>:
/// a future change that reused ring bytes mid-frame would fail loudly here
/// instead of producing an undefined read on the GPU. Across frames the
/// invariant belongs to <c>GpuFrameFlightController</c>, which waits on a
/// slot's fence in <c>BeginFrame</c> before this state is <see cref="Reset"/>.
/// </para>
/// </summary>
internal sealed class GlRingBufferState
{
private readonly int _capacityBytes;
private uint _cursor;
private int _dirtyStart = -1;
private int _dirtyEnd = -1;
private int _flushedEnd;
public GlRingBufferState(int capacityBytes)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacityBytes);
_capacityBytes = capacityBytes;
}
public int CapacityBytes => _capacityBytes;
/// <summary>Bytes handed out since the last <see cref="Reset"/>.</summary>
public uint AllocatedBytes => _cursor;
/// <summary>
/// The exclusive end of the bytes already handed to the GPU this frame.
/// Every subsequent write must start at or above it.
/// </summary>
public int FlushedEndBytes => _flushedEnd;
/// <summary>
/// Reserves <paramref name="byteCount"/> bytes aligned to
/// <paramref name="alignmentBytes"/>, returning the aligned offset.
/// Throws — rather than truncating — when the request would exceed the
/// slot's capacity, because a silently shortened allocation would corrupt
/// the frame invisibly.
/// </summary>
public uint Allocate(int byteCount, uint alignmentBytes)
{
ArgumentOutOfRangeException.ThrowIfNegative(byteCount);
uint aligned = AlignUp(_cursor, alignmentBytes);
long end = (long)aligned + byteCount;
if (end > _capacityBytes)
{
throw new InvalidOperationException(
$"Ring allocation of {byteCount} bytes at aligned offset {aligned} " +
$"needs {end} bytes; the ring slot is {_capacityBytes} bytes. " +
"Increase the per-slot ring capacity (GlGpuDevice's ringCapacityBytesPerSlot).");
}
_cursor = (uint)end;
if (byteCount > 0)
MarkDirty((int)aligned, (int)end);
return aligned;
}
/// <summary>Starts a new frame: rewinds the allocation cursor. Dirty state is untouched — a flush always runs before this is called.</summary>
public void Reset()
{
_cursor = 0;
_dirtyStart = -1;
_dirtyEnd = -1;
_flushedEnd = 0;
}
/// <summary>
/// Returns the byte range written since the last flush (start, length),
/// or (0, 0) when nothing is dirty, and clears the watermark while
/// advancing <see cref="FlushedEndBytes"/> over it.
/// </summary>
public (int Start, int Length) TakeDirtyRange()
{
if (_dirtyStart < 0)
return (0, 0);
(int start, int length) = (_dirtyStart, _dirtyEnd - _dirtyStart);
_flushedEnd = Math.Max(_flushedEnd, _dirtyEnd);
_dirtyStart = -1;
_dirtyEnd = -1;
return (start, length);
}
public bool HasDirtyBytes => _dirtyStart >= 0;
/// <summary>
/// Records that <c>[start, end)</c> was written. Internal rather than
/// private for the same reason <see cref="AlignUp"/> is: the forward-only
/// guard below is unreachable through <see cref="Allocate"/> by
/// construction, so proving it fires at all needs a direct call.
/// </summary>
internal void MarkDirty(int start, int end)
{
if (start < _flushedEnd)
{
throw new InvalidOperationException(
$"Ring write [{start}, {end}) reaches below the {_flushedEnd} bytes already " +
"uploaded this frame. Ring uploads are issued with GL_MAP_UNSYNCHRONIZED_BIT, " +
"so rewriting bytes a submitted draw may still be reading is undefined — the " +
"allocation cursor must only move forward until Reset.");
}
_dirtyStart = _dirtyStart < 0 ? start : Math.Min(_dirtyStart, start);
_dirtyEnd = Math.Max(_dirtyEnd, end);
}
internal static uint AlignUp(uint value, uint alignmentBytes) =>
alignmentBytes <= 1 ? value : (value + alignmentBytes - 1) / alignmentBytes * alignmentBytes;
}

View file

@ -1,62 +0,0 @@
namespace AcDream.App.Rendering.Gpu.Gl;
/// <summary>
/// Pure bump-plus-free-list allocator for the GL texture table (the storage
/// buffer of bindless handles at <see cref="GpuBindingModel.StorageTextureTable"/>).
///
/// GL-free by design: it knows nothing about bindless handles, retirement
/// queues, or the SSBO itself. <see cref="GlGpuDevice.RegisterTexture"/> calls
/// <see cref="Allocate"/> to get a slot to write a handle into;
/// <see cref="GlGpuDevice.ReleaseTextureSlot"/> defers the call to
/// <see cref="Release"/> through the device's <c>IGpuResourceRetirementQueue</c>
/// so a freed slot is never handed back out while a submitted frame could
/// still be reading the old handle at that index.
/// </summary>
internal sealed class GlTextureSlotAllocator
{
private readonly uint _capacity;
private readonly Stack<uint> _freeList = new();
private uint _nextBumpSlot;
public GlTextureSlotAllocator(uint capacity)
{
ArgumentOutOfRangeException.ThrowIfZero(capacity);
_capacity = capacity;
}
/// <summary>Slots currently handed out (bumped or reused) and not yet released.</summary>
public int LiveCount => (int)_nextBumpSlot - _freeList.Count;
public uint Allocate()
{
if (_freeList.Count > 0)
return _freeList.Pop();
if (_nextBumpSlot >= _capacity)
{
throw new InvalidOperationException(
$"The GL texture table is exhausted: {_capacity} slots are all live. " +
"Every texture cache/atlas must release slots it no longer needs before " +
"registering new ones.");
}
return _nextBumpSlot++;
}
/// <summary>
/// Returns a slot to the free list. Callers must only call this once the
/// retirement queue confirms no live frame could still reference the slot.
/// </summary>
public void Release(uint slot)
{
if (slot >= _nextBumpSlot)
{
throw new ArgumentOutOfRangeException(
nameof(slot),
slot,
"Cannot release a slot that was never allocated.");
}
_freeList.Push(slot);
}
}

View file

@ -6,9 +6,6 @@ internal enum GpuBackendKind
/// <summary>Test double — records calls, owns no driver objects.</summary>
Recording,
/// <summary>OpenGL 4.3 + bindless/MDI. Deleted at Campaign V slice V11.</summary>
OpenGl,
/// <summary>Vulkan 1.3 core.</summary>
Vulkan,
}

View file

@ -1,7 +1,6 @@
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Gl;
using AcDream.App.UI;
using AcDream.App.World;
using AcDream.Content;
@ -46,9 +45,9 @@ internal interface IPaperdollPoseApplicator
}
/// <summary>
/// Owns paperdoll dirty/rebuild state and the private FBO presentation edge.
/// The GL renderer remains a borrowed resource disposed by the existing window
/// shutdown transaction.
/// Owns paperdoll dirty/rebuild state and the private render-target
/// presentation edge. The renderer remains a borrowed resource disposed by
/// the existing window shutdown transaction.
/// </summary>
internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
{

View file

@ -2,7 +2,6 @@ using AcDream.App.Rendering.Wb;
using AcDream.App.UI;
using AcDream.Core.Lighting;
using AcDream.Core.World;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
@ -18,8 +17,7 @@ public sealed class PaperdollViewportRenderer :
private readonly PrivateEntityViewportRenderer _renderer;
internal PaperdollViewportRenderer(
GL? gl,
IWorldPassScope? scope,
IWorldPassScope scope,
AcDream.App.Rendering.Gpu.IGpuDevice device,
ICurrentGpuFrameSource frames,
WbDrawDispatcher dispatcher,
@ -28,7 +26,6 @@ public sealed class PaperdollViewportRenderer :
IWbMeshAdapter meshAdapter)
{
_renderer = new PrivateEntityViewportRenderer(
gl,
scope,
device,
frames,

View file

@ -185,12 +185,11 @@ public sealed unsafe partial class ParticleRenderer
}
/// <summary>
/// True when mesh particles can be submitted at all. The GL arm answers with
/// its second <c>Shader</c>, which is only built when a shared mesh arena
/// exists; the RHI arm answers with the pipelines the same condition builds.
/// True when mesh particles can be submitted at all. The GL arm's second
/// <c>Shader</c> answer was deleted at Campaign V slice V11; only the RHI
/// pipelines remain, built exactly when a shared mesh arena exists.
/// </summary>
private bool MeshParticlesAvailable =>
_glContext is null ? _meshAlphaPipeline is not null : _meshShader is not null;
private bool MeshParticlesAvailable => _meshAlphaPipeline is not null;
private void CreateRhiResources(IGpuDevice device, int sampleCount)
{

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,5 @@
using System;
using System.Linq;
using System.Numerics;
using AcDream.App.Rendering.Wb;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
@ -42,196 +39,42 @@ namespace AcDream.App.Rendering;
/// depth write lands only inside the slice region, matching retail's clipped
/// fan.</para>
///
/// <para>Self-contained GL state (feedback_render_self_contained_gl_state):
/// sets everything it depends on, restores the frame-global convention on
/// exit, no early-outs between set and restore.</para>
/// <para>The GL arm's inline program (rehomed clip planes, raw uniform
/// locations) and this class's own dynamic VAO/VBO ring were deleted at
/// Campaign V slice V11; the RHI arm compiles
/// <c>Rendering/Shaders/portal_depth.{vert,frag}</c> from committed SPIR-V,
/// and its per-frame fan vertices come from the frame ring instead.</para>
/// </summary>
public sealed partial class PortalDepthMaskRenderer : IDisposable
{
/// <summary>
/// The GL arm's inline program. Campaign V slice V6l added a SECOND arm that
/// compiles <c>Rendering/Shaders/portal_depth.{vert,frag}</c> — the same body,
/// with its loose uniforms rehomed onto the shared push block and the clip
/// planes onto the <c>TerrainClip</c> UBO — because a Vulkan pipeline needs a
/// named, SPIR-V-compiled pair. This string stays because the GL arm keeps
/// its raw path through to V10 (plan §5.5.6) and because rehoming its clip
/// planes onto UBO binding 2 would clobber the terrain clip block that
/// <c>ClipFrame</c> binds there globally on GL. The two are kept in step by
/// <c>PortalDepthShaderParityTests</c>, and this one is deleted at V11.
/// </summary>
internal const string VertSrc = @"#version 430 core
layout(location = 0) in vec3 aPos;
uniform mat4 uViewProjection;
uniform int uPlaneCount;
uniform vec4 uPlanes[8];
uniform int uForceFarZ;
uniform float uDepthBias; // NDC bias toward the viewer (mark pass only)
uniform float uDepthBiasEyeCapN; // eye-span cap x near plane (#129; see MarkBiasNdc)
out float gl_ClipDistance[8];
void main()
{
vec4 clipPos = uViewProjection * vec4(aPos, 1.0);
for (int i = 0; i < 8; i++)
gl_ClipDistance[i] = (i < uPlaneCount) ? dot(uPlanes[i], clipPos) : 1.0;
if (uForceFarZ == 1)
clipPos.z = clipPos.w * 0.99999988; // retail far-z punch constant (0x0059bc90 tail)
else if (uDepthBias > 0.0)
{
// #117 mark-pass bias, #129 eye-space cap. clipPos.w = eye depth d;
// an NDC bias b spans ~b*d*d/near meters of eye depth, so the
// constant-NDC form alone reached METERS at distance (door-shaped
// leaks through hills/houses). Keep in sync with MarkBiasNdc.
float biasNdc = min(uDepthBias, uDepthBiasEyeCapN / max(clipPos.w * clipPos.w, 1e-6));
clipPos.z -= biasNdc * clipPos.w;
}
gl_Position = clipPos;
}";
internal const string FragSrc = @"#version 430 core
void main() { } // depth-only: color writes are masked off by the caller state
";
private readonly GL? _glContext;
private GL _gl => _glContext
?? throw new InvalidOperationException(
"PortalDepthMaskRenderer's GL arm was reached on a backend with no GL context "
+ "(campaign plan slice V6l; the RHI arm lives in PortalDepthMaskRenderer.Rhi.cs).");
private readonly uint _program;
private readonly int _locViewProjection;
private readonly int _locPlaneCount;
private readonly int _locPlanes;
private readonly int _locForceFarZ;
private readonly int _locDepthBias;
private readonly int _locDepthBiasEyeCapN;
private readonly ResourceCleanupGroup _resources;
/// <summary>Shared by both arms: the largest fan <see cref="DrawDepthFan"/> accepts.</summary>
private const int MaxFanVerts = 32;
private readonly float[] _scratch = new float[MaxFanVerts * 3];
private sealed class FrameBufferSet
{
public uint Vao;
public uint Vbo;
public int CapacityBytes;
public int UsedVertices;
}
private readonly FrameBufferSet?[] _frameBuffers = new FrameBufferSet[3];
private FrameBufferSet? _activeFrameBuffer;
internal long DynamicBufferCapacityBytes =>
_frameBuffers.Sum(set => (long)(set?.CapacityBytes ?? 0));
public PortalDepthMaskRenderer(GL gl)
{
_glContext = gl ?? throw new ArgumentNullException(nameof(gl));
var resources = new ResourceCleanupGroup();
try
{
_program = ShaderProgramConstruction.Build(
new GlShaderProgramBuildApi(gl),
VertSrc,
FragSrc);
resources.Add(
"portal-depth program",
() => GlResourceCommand.DeleteProgram(
gl,
_program,
$"delete PortalDepthMask program {_program}"));
(
_locViewProjection,
_locPlaneCount,
_locPlanes,
_locForceFarZ,
_locDepthBias,
_locDepthBiasEyeCapN) = GlResourceCommand.Execute(
_gl,
"resolve PortalDepthMask uniforms",
() => (
_gl.GetUniformLocation(_program, "uViewProjection"),
_gl.GetUniformLocation(_program, "uPlaneCount"),
_gl.GetUniformLocation(_program, "uPlanes"),
_gl.GetUniformLocation(_program, "uForceFarZ"),
_gl.GetUniformLocation(_program, "uDepthBias"),
_gl.GetUniformLocation(_program, "uDepthBiasEyeCapN")));
for (int i = 0; i < _frameBuffers.Length; i++)
{
FrameBufferSet set = CreateFrameBufferSet(resources, i);
_frameBuffers[i] = set;
}
}
catch (Exception constructionFailure)
{
resources.RollbackConstructionAndThrow(
"PortalDepthMaskRenderer construction failed and its GL prefix did not cleanly roll back.",
constructionFailure);
throw new System.Diagnostics.UnreachableException();
}
_resources = resources;
}
/// <summary>
/// The GL arm's per-flight VAO/VBO ring this used to report on was deleted
/// at Campaign V slice V11: the RHI arm draws every fan from a ring
/// allocation that lives until its frame retires, so there is no
/// persistent dynamic-buffer pool left to size.
/// </summary>
internal long DynamicBufferCapacityBytes => 0;
/// <summary>
/// Selects the GPU-fenced frame slot and resets its append cursor. Portal
/// fans written during one frame occupy distinct ranges, so later portal
/// slices never overwrite vertices still referenced by earlier draws.
///
/// <para>The GL arm's per-flight VAO/VBO ring this used to activate was
/// deleted at Campaign V slice V11 — the RHI arm owns no per-flight ring
/// at all, because every frame ring allocation is already distinct memory
/// that lives until the frame retires — so all this edge carries now is
/// the started latch.</para>
/// </summary>
public void BeginFrame(int frameSlot)
{
if ((uint)frameSlot >= (uint)_frameBuffers.Length)
throw new ArgumentOutOfRangeException(nameof(frameSlot));
// Slice V6l: the RHI arm owns no per-flight VBO ring — every frame ring
// allocation is already distinct memory that lives until the frame
// retires — so all this edge carries there is the started latch.
ArgumentOutOfRangeException.ThrowIfNegative(frameSlot);
_rhiFrameStarted = true;
if (_glContext is null)
return;
_activeFrameBuffer = _frameBuffers[frameSlot];
_activeFrameBuffer!.UsedVertices = 0;
}
private FrameBufferSet CreateFrameBufferSet(
ResourceCleanupGroup resources,
int frameIndex)
{
uint vao = TrackedGlResource.CreateVertexArray(
_gl,
"PortalDepthMask frame VAO creation");
RetryableGpuResourceRelease vaoRelease =
TrackedGlResource.CreateRetryableVertexArrayDeletion(
_gl,
vao,
"PortalDepthMask frame VAO disposal");
resources.Add($"portal-depth frame {frameIndex} VAO", vaoRelease.Run);
uint vbo = TrackedGlResource.CreateBuffer(
_gl,
"PortalDepthMask frame VBO creation");
var set = new FrameBufferSet { Vao = vao, Vbo = vbo };
RetryableGpuResourceRelease vboRelease =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
vbo,
() => set.CapacityBytes,
"PortalDepthMask frame VBO disposal");
resources.Add($"portal-depth frame {frameIndex} VBO", vboRelease.Run);
GlResourceCommand.Execute(_gl, "configure PortalDepthMask frame buffers", () =>
{
_gl.BindVertexArray(set.Vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, set.Vbo);
unsafe
{
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, 3 * sizeof(float), (void*)0);
}
_gl.EnableVertexAttribArray(0);
_gl.BindVertexArray(0);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
});
return set;
}
/// <summary>
@ -303,143 +146,8 @@ void main() { } // depth-only: color writes are masked off by the caller state
{
if (worldVerts.Length < 3)
return;
if (_glContext is null)
{
DrawDepthFanRhi(worldVerts, in viewProjection, planes, forceFarZ);
return;
}
FrameBufferSet frameBuffer = _activeFrameBuffer
?? throw new InvalidOperationException("BeginFrame must be called before drawing portal depth masks.");
int n = Math.Min(worldVerts.Length, MaxFanVerts);
int planeCount = Math.Min(planes.Length, 8);
int firstVertex = frameBuffer.UsedVertices;
int requiredBytes = checked((firstVertex + n) * 3 * sizeof(float));
for (int i = 0; i < n; i++)
{
_scratch[i * 3 + 0] = worldVerts[i].X;
_scratch[i * 3 + 1] = worldVerts[i].Y;
_scratch[i * 3 + 2] = worldVerts[i].Z;
}
// ---- set state (everything this draw depends on) ----
_gl.UseProgram(_program);
_gl.Disable(EnableCap.Blend);
_gl.Disable(EnableCap.CullFace); // portal fans face either way
_gl.Disable(EnableCap.ScissorTest);
_gl.Enable(EnableCap.DepthTest);
_gl.ColorMask(false, false, false, false); // alpha-0 fan ≙ no color
for (int i = 0; i < planeCount; i++)
_gl.Enable(EnableCap.ClipDistance0 + i);
unsafe
{
var m = viewProjection;
_gl.UniformMatrix4(_locViewProjection, 1, false, (float*)&m);
_gl.Uniform1(_locPlaneCount, planeCount);
if (planeCount > 0)
{
Span<float> p = stackalloc float[planeCount * 4];
for (int i = 0; i < planeCount; i++)
{
p[i * 4 + 0] = planes[i].X;
p[i * 4 + 1] = planes[i].Y;
p[i * 4 + 2] = planes[i].Z;
p[i * 4 + 3] = planes[i].W;
}
fixed (float* pp = p)
_gl.Uniform4(_locPlanes, (uint)planeCount, pp);
}
_gl.BindVertexArray(frameBuffer.Vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, frameBuffer.Vbo);
if (frameBuffer.CapacityBytes < requiredBytes)
{
int newCapacity = DynamicBufferCapacity.Grow(
frameBuffer.CapacityBytes,
requiredBytes);
TrackedGlResource.AllocateBufferStorage(
_gl,
GLEnum.ArrayBuffer,
frameBuffer.Vbo,
frameBuffer.CapacityBytes,
newCapacity,
GLEnum.DynamicDraw,
"PortalDepthMask frame VBO growth");
frameBuffer.CapacityBytes = newCapacity;
}
fixed (float* v = _scratch)
_gl.BufferSubData(
BufferTargetARB.ArrayBuffer,
(nint)(firstVertex * 3 * sizeof(float)),
(nuint)(n * 3 * sizeof(float)),
v);
frameBuffer.UsedVertices += n;
if (!forceFarZ)
{
// ── SEAL: retail-verbatim single pass ──
_gl.DepthFunc(DepthFunction.Always);
_gl.DepthMask(true);
_gl.Uniform1(_locForceFarZ, 0);
_gl.Uniform1(_locDepthBias, 0f);
_gl.DrawArrays(PrimitiveType.TriangleFan, firstVertex, (uint)n);
}
else
{
// ── PUNCH pass A: stencil-mark visible aperture pixels ──
_gl.Enable(EnableCap.StencilTest);
_gl.StencilFunc(StencilFunction.Always, 1, 0xFF);
_gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace);
_gl.StencilMask(0xFF);
_gl.DepthFunc(DepthFunction.Lequal);
_gl.DepthMask(false);
_gl.Uniform1(_locForceFarZ, 0);
_gl.Uniform1(_locDepthBias, PunchMarkDepthBias);
_gl.Uniform1(_locDepthBiasEyeCapN,
PunchMarkBiasEyeCapMeters * CameraNearPlaneMeters);
_gl.DrawArrays(PrimitiveType.TriangleFan, firstVertex, (uint)n);
// ── PUNCH pass B: far-Z write on marked pixels only;
// zero the stencil as we go (self-cleaning) ──
_gl.StencilFunc(StencilFunction.Equal, 1, 0xFF);
_gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Zero);
_gl.DepthFunc(DepthFunction.Always);
_gl.DepthMask(true);
_gl.Uniform1(_locForceFarZ, 1);
_gl.Uniform1(_locDepthBias, 0f);
_gl.DrawArrays(PrimitiveType.TriangleFan, firstVertex, (uint)n);
_gl.Disable(EnableCap.StencilTest);
}
_gl.BindVertexArray(0);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
}
// ---- restore the frame-global convention ----
for (int i = 0; i < planeCount; i++)
_gl.Disable(EnableCap.ClipDistance0 + i);
_gl.ColorMask(true, true, true, true);
_gl.DepthMask(true);
_gl.DepthFunc(DepthFunction.Less);
// Renderers opt into culling locally; the shared frame convention is
// cull-off. Keep this success exit identical to frame-abort recovery.
_gl.Disable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.CW);
_gl.UseProgram(0);
DrawDepthFanRhi(worldVerts, in viewProjection, planes, forceFarZ);
}
public void Dispose()
{
if (_glContext is null)
{
DisposeRhiResources();
return;
}
_resources.RetryCleanup();
}
public void Dispose() => DisposeRhiResources();
}

View file

@ -12,7 +12,6 @@ using DatReaderWriter;
using AcDream.Content;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
@ -24,14 +23,12 @@ namespace AcDream.App.Rendering;
/// retained gameplay UI.
///
/// <para><b>Campaign V slice V6m.</b> This was the last raw-GL world-adjacent
/// renderer; it now draws on both arms. Nothing about the scene changed — the
/// same synthetic Setup, the same 40 fps sequence, the same rotation and the
/// same distant light, through the same (already dual-arm)
/// <see cref="WbDrawDispatcher"/>. What forked is where the draw is recorded:
/// GL sets ambient capability state under a <see cref="GLStateScope"/> and draws
/// into whatever framebuffer is bound, while the RHI arm opens a pass of its own
/// against the backbuffer and publishes it on <see cref="IWorldPassScope"/> for
/// the span of the draw, exactly as the two offscreen viewports do
/// renderer, and its GL arm was deleted at slice V11. Nothing about the scene
/// changed — the same synthetic Setup, the same 40 fps sequence, the same
/// rotation and the same distant light, through the same
/// <see cref="WbDrawDispatcher"/>. The RHI arm opens a pass of its own against
/// the backbuffer and publishes it on <see cref="IWorldPassScope"/> for the
/// span of the draw, exactly as the two offscreen viewports do
/// (<see cref="PrivateEntityViewportRenderer"/>, slice V6l).</para>
/// </summary>
public sealed class PortalTunnelPresentation : IDisposable
@ -40,13 +37,6 @@ public sealed class PortalTunnelPresentation : IDisposable
public const uint AnimationClientEnum = 0x10000002u;
public const uint ClientEnumCategory = 7u;
// UIViewportObject::DrawContent @ 0x006950A5 calls
// RenderDeviceD3D::Clear(4, RGBAColor_Black, 1). Clear @ 0x0059FD30
// maps retail flag 4 to D3DCLEAR_ZBUFFER only: portal space deliberately
// preserves the black color target established by SceneTool::BeginScene's
// whole-frame Clear(7) while replacing the hidden world viewport.
internal const ClearBufferMask RetailViewportClearMask = ClearBufferMask.DepthBufferBit;
/// <summary>
/// The colour the RHI arm's pass loads with, and the one value that makes
/// its <c>Clear</c> load-op equivalent to GL's depth-only clear.
@ -74,22 +64,17 @@ public sealed class PortalTunnelPresentation : IDisposable
private static readonly HashSet<uint> AnimatedIds = new() { SyntheticEntityId };
/// <summary>The GL arm's context, or null on a backend that has none.</summary>
private readonly GL? _glContext;
/// <summary>
/// The world pass scope, or null on the GL arm.
///
/// <para><see cref="WbDrawDispatcher"/>'s RHI arm borrows its pass from the
/// scope rather than opening one, so a presentation that opens a pass of its
/// own has to publish it there for the duration of the draw. On the GL arm
/// the dispatcher records against whatever framebuffer is bound, so nothing
/// is published.</para>
/// The world pass scope this presentation's own pass publishes into for
/// the span of the draw. <see cref="WbDrawDispatcher"/> borrows its pass
/// from the scope rather than opening one, so a presentation that opens a
/// pass of its own has to publish it there. The raw-GL arm this used to be
/// optional for was deleted at Campaign V slice V11.
/// </summary>
private readonly IWorldPassScope? _scope;
private readonly IWorldPassScope _scope;
/// <summary>The frame the RHI arm's pass is opened on; null on the GL arm.</summary>
private readonly ICurrentGpuFrameSource? _frames;
/// <summary>The frame this presentation's own pass is opened on.</summary>
private readonly ICurrentGpuFrameSource _frames;
private readonly WbDrawDispatcher _dispatcher;
private readonly SceneLightingUboBinding _lightUbo;
@ -117,9 +102,8 @@ public sealed class PortalTunnelPresentation : IDisposable
private bool _disposed;
private PortalTunnelPresentation(
GL? gl,
IWorldPassScope? scope,
ICurrentGpuFrameSource? frames,
IWorldPassScope scope,
ICurrentGpuFrameSource frames,
WbDrawDispatcher dispatcher,
SceneLightingUboBinding lightUbo,
IWbMeshAdapter meshAdapter,
@ -132,7 +116,6 @@ public sealed class PortalTunnelPresentation : IDisposable
Action<string?>? displayNotice,
IDisposable? displayNoticeLifetime)
{
_glContext = gl;
_scope = scope;
_frames = frames;
_dispatcher = dispatcher;
@ -173,9 +156,8 @@ public sealed class PortalTunnelPresentation : IDisposable
/// Composition is the only caller.</para>
/// </summary>
internal static PortalTunnelPresentation CreateRequired(
GL? gl,
IWorldPassScope? scope,
ICurrentGpuFrameSource? frames,
IWorldPassScope scope,
ICurrentGpuFrameSource frames,
IDatReaderWriter dats,
IAnimationLoader animationLoader,
IAnimationHookSink hookSink,
@ -186,14 +168,8 @@ public sealed class PortalTunnelPresentation : IDisposable
IDisposable? displayNoticeLifetime = null,
Random? random = null)
{
if (gl is null && (scope is null || frames is null))
{
throw new ArgumentNullException(
nameof(scope),
"A backend without a GL context must supply a world pass scope and a frame source "
+ "for portal space to open its pass on.");
}
ArgumentNullException.ThrowIfNull(scope);
ArgumentNullException.ThrowIfNull(frames);
ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(animationLoader);
ArgumentNullException.ThrowIfNull(hookSink);
@ -215,7 +191,6 @@ public sealed class PortalTunnelPresentation : IDisposable
animation is not null);
return new PortalTunnelPresentation(
gl,
scope,
frames,
dispatcher,
@ -335,51 +310,21 @@ public sealed class PortalTunnelPresentation : IDisposable
_camera.Aspect = width / (float)height;
_camera.UseSmartBoxFov(smartBoxProjection);
if (_glContext is { } gl)
{
DrawGl(gl, width, height);
return;
}
DrawRhi();
}
private void DrawGl(GL gl, int width, int height)
{
using var scope = new GLStateScope(gl);
gl.Viewport(0, 0, (uint)width, (uint)height);
gl.Disable(EnableCap.ScissorTest);
gl.ClearDepth(1.0);
gl.DepthMask(true);
gl.Clear(RetailViewportClearMask);
gl.Enable(EnableCap.DepthTest);
gl.DepthFunc(DepthFunction.Less);
gl.Enable(EnableCap.CullFace);
gl.CullFace(TriangleFace.Back);
gl.FrontFace(FrontFaceDirection.Ccw);
gl.Disable(EnableCap.Blend);
DrawScene();
}
/// <summary>
/// The RHI arm. Every capability the GL arm sets by hand is baked into the
/// dispatcher's pipelines, and the pass sets its own full-attachment
/// viewport, so what remains is the pass itself: a backbuffer pass at the
/// world's sample count that clears colour and depth (see
/// <see cref="RetailPortalSpaceClearColor"/> for why re-clearing colour is
/// exact) and is published as the scope's for the span of the draw.
/// Every capability the raw-GL arm used to set by hand (deleted at
/// Campaign V slice V11) is baked into the dispatcher's pipelines, and the
/// pass sets its own full-attachment viewport, so what remains is the
/// pass itself: a backbuffer pass at the world's sample count that clears
/// colour and depth (see <see cref="RetailPortalSpaceClearColor"/> for why
/// re-clearing colour is exact) and is published as the scope's for the
/// span of the draw.
/// </summary>
private void DrawRhi()
{
IWorldPassScope scope = _scope
?? throw new InvalidOperationException(
"Portal space reached its RHI arm with no world pass scope.");
IGpuFrame frame = (_frames ?? throw new InvalidOperationException(
"Portal space reached its RHI arm with no frame source."))
.CurrentFrame
IGpuFrame frame = _frames.CurrentFrame
?? throw new InvalidOperationException(
"Portal space requires an open IGpuFrame (see GpuDeviceFrameLifetime).");
@ -387,11 +332,11 @@ public sealed class PortalTunnelPresentation : IDisposable
GpuPassDescription.BackbufferClear(
"portal-space",
RetailPortalSpaceClearColor,
scope.SampleCount));
_scope.SampleCount));
// Published AFTER the pass opens and BEFORE the light upload: publishing
// resets the frame-global sections, and the distant light installed below
// is the one this scene wants rather than the world's.
using IDisposable publication = scope.Publish(encoder);
using IDisposable publication = _scope.Publish(encoder);
DrawScene();
}

View file

@ -4,7 +4,6 @@ using AcDream.App.Rendering.Wb;
using AcDream.App.UI;
using AcDream.Core.Lighting;
using AcDream.Core.World;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
@ -41,41 +40,31 @@ internal interface IPrivateEntityViewportCamera : ICamera
/// nothing depends on inheritance and the GL and Vulkan backends agree about
/// what <c>Target: null</c> means.</para>
///
/// <para>The DRAW inside the pass is still raw GL: <c>WbDrawDispatcher</c> keeps
/// its GL arm through to V10 (§5.5.6), so this renderer opens an RHI pass and
/// then lets a raw-GL renderer record into the framebuffer that pass bound. The
/// surrounding <see cref="GLStateScope"/> is what restores the previous
/// framebuffer, viewport and capability state afterwards, exactly as before.</para>
/// <para>The GL arm this renderer used to open an RHI pass and delegate a
/// raw-GL <c>WbDrawDispatcher</c> into (through V10, §5.5.6) was deleted at
/// Campaign V slice V11: <c>WbDrawDispatcher</c> now records into the pass
/// this renderer publishes on both call sites the same way.</para>
/// </summary>
internal sealed unsafe class PrivateEntityViewportRenderer :
internal sealed class PrivateEntityViewportRenderer :
IUiViewportRenderer,
IDisposable
{
private const uint PrivateLandblockId = 0u;
/// <summary>
/// The GL arm's context, or null on a backend that has none. Campaign V
/// slice V6l: both retained-UI viewports render on either arm now.
/// </summary>
private readonly GL? _glContext;
private GL RequireGl => _glContext
?? throw new InvalidOperationException(
$"The {_diagnosticName} reached its GL arm on a backend with no GL context.");
private readonly IGpuDevice _device;
private readonly ICurrentGpuFrameSource _frames;
/// <summary>
/// The world pass scope, or null on the GL arm.
/// The world pass scope this renderer's own pass publishes into for the
/// span of the draw. <c>WbDrawDispatcher</c> borrows its pass from the
/// scope rather than opening one, so this renderer — which opens a pass of
/// its own to draw into an offscreen target — has to publish it there.
///
/// <para>Slice V6l. <c>WbDrawDispatcher</c>'s RHI arm borrows its pass from
/// the scope rather than opening one, so a viewport that opens a pass of its
/// own has to publish it there for the duration of the draw. On the GL arm
/// the dispatcher records against whatever framebuffer is bound, which is
/// what the pass bound, so nothing is published.</para>
/// <para>The raw-GL arm this used to be optional for (the GL dispatcher
/// recorded against whatever framebuffer was already bound, needing no
/// publication) was deleted at Campaign V slice V11.</para>
/// </summary>
private readonly IWorldPassScope? _scope;
private readonly IWorldPassScope _scope;
private readonly WbDrawDispatcher _dispatcher;
private readonly SceneLightingUboBinding _lightUbo;
@ -96,8 +85,7 @@ internal sealed unsafe class PrivateEntityViewportRenderer :
private SyntheticEntityMeshReferenceOwner? _meshReferences;
public PrivateEntityViewportRenderer(
GL? gl,
IWorldPassScope? scope,
IWorldPassScope scope,
IGpuDevice device,
ICurrentGpuFrameSource frames,
WbDrawDispatcher dispatcher,
@ -110,15 +98,10 @@ internal sealed unsafe class PrivateEntityViewportRenderer :
{
if (renderId == 0u)
throw new ArgumentOutOfRangeException(nameof(renderId));
if (gl is null && scope is null)
{
throw new ArgumentNullException(
nameof(scope),
"A backend without a GL context must publish a world pass scope for the viewport to draw into.");
}
_glContext = gl;
_scope = scope;
_scope = scope ?? throw new ArgumentNullException(
nameof(scope),
"The viewport must publish a world pass scope to draw into.");
_device = device ?? throw new ArgumentNullException(nameof(device));
_frames = frames ?? throw new ArgumentNullException(nameof(frames));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
@ -136,10 +119,11 @@ internal sealed unsafe class PrivateEntityViewportRenderer :
}
/// <summary>
/// A GL framebuffer's colour texture samples bottom-up; a Vulkan render
/// target's does not. See <see cref="IUiViewportRenderer.TextureIsBottomUp"/>.
/// A GL framebuffer's colour texture used to sample bottom-up; that arm was
/// deleted at Campaign V slice V11, and a Vulkan render target's does not.
/// See <see cref="IUiViewportRenderer.TextureIsBottomUp"/>.
/// </summary>
public bool TextureIsBottomUp => _glContext is not null;
public bool TextureIsBottomUp => false;
public void SetEntity(WorldEntity? entity)
{
@ -222,14 +206,6 @@ internal sealed unsafe class PrivateEntityViewportRenderer :
?? throw new InvalidOperationException(
$"The {_diagnosticName} requires an open IGpuFrame (see GpuDeviceFrameLifetime).");
// The pass's clear is not scissored on either backend, but GL's glClear
// is: a doorway slice earlier in the frame can have left the scissor on,
// and this target is not confined to it.
using GLStateScope? glState = _glContext is { } scissorGl
? new GLStateScope(scissorGl)
: null;
_glContext?.Disable(EnableCap.ScissorTest);
using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription
{
Name = _diagnosticName,
@ -246,31 +222,13 @@ internal sealed unsafe class PrivateEntityViewportRenderer :
SampleCount = 1,
});
// GL's BeginPass deliberately does not touch viewport or scissor, and
// the renderer that draws inside this pass is still raw GL, so the
// remaining state is set here exactly as it always was. On the RHI arm
// every one of these is baked into the dispatcher's pipelines and the
// pass sets its own full-attachment viewport, so there is nothing to do.
if (_glContext is { } stateGl)
{
stateGl.Viewport(0, 0, (uint)width, (uint)height);
stateGl.Enable(EnableCap.DepthTest);
stateGl.DepthFunc(DepthFunction.Less);
stateGl.Enable(EnableCap.CullFace);
stateGl.CullFace(TriangleFace.Back);
stateGl.FrontFace(FrontFaceDirection.Ccw);
stateGl.Disable(EnableCap.Blend);
}
// Slice V6l: the dispatcher's RHI arm borrows its pass from the scope,
// so this pass has to BE the scope's for the span of the draw. Published
// after the world phase has closed its own, which is why it does not
// nest; the sections it resets are republished by UploadCreatureLight
// The dispatcher borrows its pass from the scope, so this pass has to
// BE the scope's for the span of the draw. Published after the world
// phase has closed its own, which is why it does not nest; the
// sections it resets are republished by UploadCreatureLight
// immediately below, which is the private lighting this view wants
// rather than the world's.
using IDisposable? publication = _glContext is null
? _scope!.Publish(encoder)
: null;
using IDisposable publication = _scope.Publish(encoder);
UploadCreatureLight();

View file

@ -1,310 +0,0 @@
using System.Collections.Concurrent;
using AcDream.Content;
using DatReaderWriter;
using Microsoft.Extensions.Logging.Abstractions;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// The subset of the production render stack that the UI Studio needs:
/// GL + dats + UiHost + the WB mesh pipeline. Constructed from the same
/// classes and the same order as <see cref="GameWindow.OnLoad"/>, minus
/// terrain / sky / physics / streaming.
/// </summary>
public sealed record RenderStack(
GL Gl,
IDatReaderWriter Dats,
string ShaderDir,
Wb.BindlessSupport Bindless,
TextureCache TextureCache,
Shader MeshShader,
Wb.WbMeshAdapter MeshAdapter,
Wb.EntitySpawnAdapter EntitySpawnAdapter,
Wb.WbDrawDispatcher DrawDispatcher,
SceneLightingUboBinding LightingUbo,
AcDream.App.UI.UiHost UiHost,
AcDream.App.UI.UiDatFont? VitalsDatFont,
AcDream.App.UI.UiDatFont? LargeDatFont) : System.IDisposable
{
internal GpuFrameFlightController FrameFlights { get; init; } = null!;
/// <summary>
/// Campaign V slice V4a: the studio's own RHI device (mirrors
/// <see cref="HostInputCameraComposition"/>'s production one — the studio
/// composes its own render stack independently of GameWindow).
/// </summary>
internal AcDream.App.Rendering.Gpu.IGpuDevice GpuDevice { get; init; } = null!;
/// <summary>
/// Drives <see cref="AcDream.App.Rendering.Gpu.IGpuDevice.BeginFrame"/>/<c>IGpuFrame.End</c>
/// once per <see cref="BeginFrame"/>/<see cref="EndFrame"/> pair and
/// exposes the open frame to <see cref="UiHost"/>'s <see cref="TextRenderer"/>.
/// </summary>
internal GpuDeviceFrameLifetime FrameLifetime { get; init; } = null!;
private ResourceShutdownTransaction? _shutdown;
internal void BeginFrame() => FrameLifetime.BeginFrame();
internal void EndFrame() => FrameLifetime.EndFrame();
/// <summary>Dispose the GL pieces this stack OWNS (everything created in
/// <see cref="RenderBootstrap.Create"/>). <see cref="Dats"/> + <see cref="Gl"/> are caller-owned
/// and NOT disposed here. Called once at studio teardown.</summary>
public void Dispose()
{
_shutdown ??= new ResourceShutdownTransaction(
new ResourceShutdownStage("submitted GPU work",
[
new("frame flight drain", FrameFlights.WaitForSubmittedWork),
]),
new ResourceShutdownStage("draw frontend",
[
new("draw dispatcher", DrawDispatcher.Dispose),
]),
new ResourceShutdownStage("mesh adapter",
[
new("mesh adapter", MeshAdapter.Dispose),
]),
new ResourceShutdownStage("remaining render stack",
[
new("texture cache", TextureCache.Dispose),
new("mesh shader", MeshShader.Dispose),
new("lighting UBO", LightingUbo.Dispose),
new("UI host", UiHost.Dispose),
// GpuDevice's own Dispose routes every resource release through
// FrameFlights as its retirement queue, so it must be disposed
// before FrameFlights below (see GlGpuDevice.Dispose's comment).
new("GPU device (RHI)", GpuDevice.Dispose),
]),
new ResourceShutdownStage("frame flight owner",
[
new("frame flights", FrameFlights.Dispose),
]));
_shutdown.CompleteOrThrow();
}
/// <summary>
/// Resolves a sprite id (0x06xxxxxx) to a (GL handle, width, height) triple.
/// Copied verbatim from GameWindow's ResolveChrome closure — it calls
/// TextureCache.GetOrUploadRenderSurface(id, out w, out h).
/// </summary>
public (uint handle, int width, int height) ResolveChrome(uint spriteId)
{
uint t = TextureCache.GetOrUploadRenderSurface(spriteId, out int w, out int h);
return (t, w, h);
}
// ── Font cache (per-stack, keyed by FontDid) ─────────────────────────────
/// <summary>
/// Cache of loaded dat fonts keyed by FontDid (0x40000000-range).
/// Populated lazily by <see cref="ResolveDatFont"/>. Thread-safe for
/// concurrent reads from the studio render loop; writes happen only
/// during the first load of each distinct FontDid.
/// </summary>
private readonly ConcurrentDictionary<uint, AcDream.App.UI.UiDatFont?> _fontCache = new();
/// <summary>
/// Lazily load and cache a dat font by its FontDid. Returns null (and
/// caches null) when the Font DBObj is absent or has no foreground surface —
/// callers fall back to the global font in that case.
///
/// <para>Pre-seeds <see cref="VitalsDatFont"/> (0x40000000) and
/// <see cref="LargeDatFont"/> (0x40000001) from the already-loaded instances
/// to avoid a redundant upload on those two ids.</para>
/// </summary>
public AcDream.App.UI.UiDatFont? ResolveDatFont(uint fontDid)
{
return _fontCache.GetOrAdd(fontDid, id =>
AcDream.App.UI.UiDatFont.Load(Dats, TextureCache, id));
}
/// <summary>
/// Pre-seeds the font cache from the two already-loaded font instances
/// (VitalsDatFont = 0x40000000, LargeDatFont = 0x40000001) so that
/// <see cref="ResolveDatFont"/> returns them without a redundant GL upload.
/// Called once by <see cref="RenderBootstrap.Create"/> after the stack is
/// fully constructed.
/// </summary>
internal void SeedFontCache()
{
if (VitalsDatFont is not null)
_fontCache.TryAdd(AcDream.App.UI.UiDatFont.DefaultFontId, VitalsDatFont);
if (LargeDatFont is not null)
_fontCache.TryAdd(0x40000001u, LargeDatFont);
}
}
/// <summary>Options for <see cref="RenderBootstrap.Create"/>.</summary>
public sealed record RenderBootstrapOptions(
AcDream.UI.Abstractions.Settings.QualitySettings Quality,
string DiagnosticsDirectory);
/// <summary>
/// Constructs the UI Studio's render stack from the production classes,
/// in the same order as <see cref="GameWindow.OnLoad"/>.
/// </summary>
public static class RenderBootstrap
{
/// <summary>
/// Build the studio's render stack. Throws <see cref="NotSupportedException"/>
/// (same message as GameWindow) if GL_ARB_bindless_texture or
/// GL_ARB_shader_draw_parameters are absent — the modern path is mandatory.
/// </summary>
public static RenderStack Create(
GL gl,
IDatReaderWriter dats,
RenderBootstrapOptions opts)
{
// --- Bindless detection (GameWindow ~1701-1723) ---
if (!Wb.BindlessSupport.TryCreate(gl, out var bindless)
|| bindless is null
|| !bindless.HasShaderDrawParameters(gl))
{
throw new NotSupportedException(
"acdream requires GL_ARB_bindless_texture + GL_ARB_shader_draw_parameters " +
"(GL 4.3+ with bindless support). Your GPU/driver does not expose these extensions. " +
"If this is unexpected, please file a bug report with your GPU vendor + driver version.");
}
// --- Shared infra (GameWindow ~1198, ~1211) ---
string shaderDir = Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders");
var lightingUbo = new SceneLightingUboBinding(gl);
// --- Mesh shader (GameWindow ~1769-1771) ---
// Campaign V slice V6e: mesh_modern has needed common.glsl since V2 —
// ACDREAM_UBO_SET appears in a layout qualifier and ACDREAM_SAMPLE_ARRAY
// at the sample site, and without the preamble both are undeclared
// identifiers, so this program has failed to link on the Studio path
// since that slice. The world composition (WorldRenderComposition) has
// always passed true; this is the same pair loaded the same way.
var meshShader = new Shader(gl,
Path.Combine(shaderDir, "mesh_modern.vert"),
Path.Combine(shaderDir, "mesh_modern.frag"),
includeCommonPreamble: true);
// --- TextureCache (GameWindow ~1774) ---
var frameFlights = new GpuFrameFlightController(gl);
// Campaign V slice V4a: the studio composes its own RHI device
// independently of GameWindow/HostInputCameraComposition, mirroring
// that composition's construction (gl + frame flights + shaders dir).
Gpu.IGpuDevice gpuDevice = new Gpu.Gl.GlGpuDevice(gl, frameFlights, shaderDir);
var gpuFrameLifetime = new GpuDeviceFrameLifetime(gpuDevice);
var textureCache = new TextureCache(
gl,
gpuDevice,
dats,
bindless,
frameFlights,
opts.DiagnosticsDirectory);
// --- AnimLoader (GameWindow ~1240) ---
var animLoader = new AcDream.Content.Vfx.RetailAnimationLoader(dats);
// --- WbMeshAdapter (GameWindow ~2286-2287) ---
var wbLogger = NullLogger<Wb.WbMeshAdapter>.Instance;
var meshAdapter = Wb.WbMeshAdapter.CreateWithLiveDatPreparedAssets(
gl,
gpuDevice,
dats,
wbLogger,
frameFlights);
// --- SequencerFactory (GameWindow ~2306-2334) ---
var capturedDats = dats;
var capturedAnimLoader = animLoader;
AcDream.Core.Physics.AnimationSequencer SequencerFactory(AcDream.Core.World.WorldEntity e)
{
if (capturedDats is not null && capturedAnimLoader is not null)
{
var setup = capturedDats.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
if (setup is not null)
{
uint mtableId = (uint)setup.DefaultMotionTable;
if (mtableId != 0)
{
var mtable = capturedDats.Get<DatReaderWriter.DBObjs.MotionTable>(mtableId);
if (mtable is not null)
return new AcDream.Core.Physics.AnimationSequencer(
setup, mtable, capturedAnimLoader);
}
// Setup exists but no motion table — no-op sequencer.
return new AcDream.Core.Physics.AnimationSequencer(
setup,
new DatReaderWriter.DBObjs.MotionTable(),
capturedAnimLoader);
}
}
// Complete fallback: empty setup + empty motion table + null loader.
return new AcDream.Core.Physics.AnimationSequencer(
new DatReaderWriter.DBObjs.Setup(),
new DatReaderWriter.DBObjs.MotionTable(),
new NullAnimLoader());
}
// --- EntitySpawnAdapter (GameWindow ~2335-2336) ---
var entitySpawnAdapter = new Wb.EntitySpawnAdapter(
textureCache, SequencerFactory, meshAdapter);
// --- EntityClassificationCache (GameWindow ~217 — field initializer, new()) ---
var classificationCache = new Wb.EntityClassificationCache();
// --- TranslucencyFadeManager (GameWindow — field initializer, new()) ---
var translucencyFades = new AcDream.Core.Rendering.TranslucencyFadeManager();
// --- WbDrawDispatcher (GameWindow ~2377-2381) ---
var drawDispatcher = new Wb.WbDrawDispatcher(
gl, meshShader, textureCache, meshAdapter, entitySpawnAdapter,
bindless, classificationCache, translucencyFades);
drawDispatcher.AlphaToCoverage = opts.Quality.AlphaToCoverage;
// --- Vitals dat font (GameWindow ~1820-1822) ---
var vitalsDatFont = AcDream.App.UI.UiDatFont.Load(dats, textureCache);
// --- Larger retail font (0x40000001, MaxCharHeight=18) for attribute row text.
// The default font (0x40000000, 16px) renders the row names too small; the 18px
// variant (confirmed in client_portal.dat 2026-06-26) matches the retail character
// window list more closely (≈ icon height ≈ 24px target, 18px is best available).
var largeDatFont = AcDream.App.UI.UiDatFont.Load(dats, textureCache, 0x40000001u);
// --- UiHost (GameWindow ~1790); pass null for debugFont (only used as
// a fallback BitmapFont for the world-space HUD — not needed for the
// UI Studio, and BitmapFont requires a system font byte array) ---
var uiHost = new AcDream.App.UI.UiHost(gpuDevice, gpuFrameLifetime, shaderDir, defaultFont: null);
var stack = new RenderStack(
Gl: gl,
Dats: dats,
ShaderDir: shaderDir,
Bindless: bindless,
TextureCache: textureCache,
MeshShader: meshShader,
MeshAdapter: meshAdapter,
EntitySpawnAdapter: entitySpawnAdapter,
DrawDispatcher: drawDispatcher,
LightingUbo: lightingUbo,
UiHost: uiHost,
VitalsDatFont: vitalsDatFont,
LargeDatFont: largeDatFont)
{
FrameFlights = frameFlights,
GpuDevice = gpuDevice,
FrameLifetime = gpuFrameLifetime,
};
// Pre-seed the font cache with the two already-uploaded atlas instances
// so ResolveDatFont(0x40000000) and ResolveDatFont(0x40000001) hit the cache
// rather than re-uploading the same GL texture a second time.
stack.SeedFontCache();
return stack;
}
// NullAnimLoader mirrors GameWindow's private NullAnimLoader (GameWindow ~13327-13330).
private sealed class NullAnimLoader : AcDream.Core.Physics.IAnimationLoader
{
public DatReaderWriter.DBObjs.Animation? LoadAnimation(uint id) => null;
}
}

View file

@ -1,113 +0,0 @@
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
internal interface IRenderFrameGlState
{
void RestoreFrameDefaults();
}
internal interface IRenderFrameGlStateApi
{
void SetCapability(EnableCap capability, bool enabled);
void ColorMask(bool red, bool green, bool blue, bool alpha);
void StencilMask(uint mask);
void DepthMask(bool enabled);
void DepthFunc(DepthFunction function);
void CullFace(TriangleFace face);
void FrontFace(FrontFaceDirection direction);
void UseProgram(uint program);
void BindVertexArray(uint vertexArray);
void BindBuffer(BufferTargetARB target, uint buffer);
}
internal sealed class SilkRenderFrameGlStateApi : IRenderFrameGlStateApi
{
private readonly GL _gl;
public SilkRenderFrameGlStateApi(GL gl)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
}
public void SetCapability(EnableCap capability, bool enabled)
{
if (enabled)
_gl.Enable(capability);
else
_gl.Disable(capability);
}
public void ColorMask(bool red, bool green, bool blue, bool alpha) =>
_gl.ColorMask(red, green, blue, alpha);
public void StencilMask(uint mask) => _gl.StencilMask(mask);
public void DepthMask(bool enabled) => _gl.DepthMask(enabled);
public void DepthFunc(DepthFunction function) => _gl.DepthFunc(function);
public void CullFace(TriangleFace face) => _gl.CullFace(face);
public void FrontFace(FrontFaceDirection direction) => _gl.FrontFace(direction);
public void UseProgram(uint program) => _gl.UseProgram(program);
public void BindVertexArray(uint vertexArray) => _gl.BindVertexArray(vertexArray);
public void BindBuffer(BufferTargetARB target, uint buffer) =>
_gl.BindBuffer(target, buffer);
}
/// <summary>
/// Restores the frame-global OpenGL convention shared by frame clear and
/// exceptional world-pass rollback. This prevents a failed doorway scissor or
/// portal depth mask from clipping/color-masking the next frame's clear.
/// </summary>
internal sealed class RenderFrameGlStateController : IRenderFrameGlState
{
private readonly IRenderFrameGlStateApi _gl;
public RenderFrameGlStateController(IRenderFrameGlStateApi gl)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
}
public void RestoreFrameDefaults()
{
_gl.SetCapability(EnableCap.ScissorTest, enabled: false);
_gl.SetCapability(EnableCap.StencilTest, enabled: false);
_gl.SetCapability(EnableCap.Blend, enabled: false);
_gl.SetCapability(EnableCap.SampleAlphaToCoverage, enabled: false);
for (int index = 0; index < ClipFrame.MaxPlanes; index++)
{
_gl.SetCapability(
EnableCap.ClipDistance0 + index,
enabled: false);
}
_gl.ColorMask(true, true, true, true);
_gl.StencilMask(0xFF);
_gl.DepthMask(true);
_gl.DepthFunc(DepthFunction.Less);
_gl.SetCapability(EnableCap.DepthTest, enabled: true);
// Renderers that need face culling establish it locally. The shared
// frame convention is culling off; SkyRenderer restores the state it
// observed, so enabling it here would leak culling into later passes.
_gl.SetCapability(EnableCap.CullFace, enabled: false);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.CW);
_gl.UseProgram(0);
_gl.BindVertexArray(0);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
}
}

View file

@ -2,6 +2,38 @@ namespace AcDream.App.Rendering;
using System.Runtime.ExceptionServices;
internal interface IRetryableResourceCleanup
{
bool IsCleanupComplete { get; }
void RetryCleanup();
}
/// <summary>
/// Thrown when a resource construction failed and the partial-construction
/// rollback it triggered could not fully complete either. Backend-neutral —
/// <see cref="ResourceCleanupGroup"/> and <c>ShaderProgramConstruction</c>
/// throw it on both the (deleted, Campaign V slice V11) GL construction path
/// and the Vulkan one.
/// </summary>
internal sealed class ResourceConstructionException : AggregateException,
IRetryableResourceCleanup
{
private readonly IRetryableResourceCleanup _cleanup;
public ResourceConstructionException(
string message,
IRetryableResourceCleanup cleanup,
IEnumerable<Exception> failures)
: base(message, failures)
{
_cleanup = cleanup ?? throw new ArgumentNullException(nameof(cleanup));
}
public bool IsCleanupComplete => _cleanup.IsCleanupComplete;
public void RetryCleanup() => _cleanup.RetryCleanup();
}
/// <summary>
/// Reverse-order, all-attempted cleanup owner used while a composite resource
/// is still under construction and after it becomes the aggregate owner.
@ -84,7 +116,7 @@ internal sealed class ResourceCleanupGroup : IRetryableResourceCleanup
}
catch (Exception cleanupFailure)
{
throw new GlResourceConstructionException(
throw new ResourceConstructionException(
message,
this,
[constructionFailure, cleanupFailure]);
@ -94,3 +126,88 @@ internal sealed class ResourceCleanupGroup : IRetryableResourceCleanup
throw new InvalidOperationException("Unreachable construction rollback path.");
}
}
/// <summary>
/// Lifetime root for cleanup work that could not finish before a throwing
/// composition factory returned control. The original exception remains the
/// retry owner; this ledger prevents it and its exact pending names from
/// becoming local-only.
///
/// <para>Backend-neutral, despite its former name
/// (<c>GlConstructionCleanupLedger</c>, deleted at Campaign V slice V11): it
/// walks any exception chain for <see cref="IRetryableResourceCleanup"/> —
/// which <see cref="ResourceConstructionException"/> implements on both
/// backends — and does not itself touch GL.</para>
/// </summary>
internal sealed class ResourceConstructionCleanupLedger : IDisposable
{
private readonly List<IRetryableResourceCleanup> _pending = [];
private bool _disposing;
public bool IsComplete => _pending.Count == 0;
public bool RetainFrom(Exception failure)
{
ArgumentNullException.ThrowIfNull(failure);
bool retained = false;
Visit(failure);
return retained;
void Visit(Exception current)
{
if (current is IRetryableResourceCleanup cleanup)
{
if (!cleanup.IsCleanupComplete && !_pending.Contains(cleanup))
_pending.Add(cleanup);
retained = true;
}
if (current is AggregateException aggregate)
{
foreach (Exception inner in aggregate.InnerExceptions)
Visit(inner);
}
else if (current.InnerException is { } inner)
{
Visit(inner);
}
}
}
public void Dispose()
{
if (_disposing || _pending.Count == 0)
return;
_disposing = true;
List<Exception>? failures = null;
try
{
for (int i = _pending.Count - 1; i >= 0; i--)
{
IRetryableResourceCleanup cleanup = _pending[i];
try
{
cleanup.RetryCleanup();
if (cleanup.IsCleanupComplete)
_pending.RemoveAt(i);
}
catch (Exception failure)
{
(failures ??= []).Add(failure);
}
}
}
finally
{
_disposing = false;
}
if (failures is not null)
{
throw new AggregateException(
"One or more failed resource construction transactions remain pending.",
failures);
}
}
}

View file

@ -5,7 +5,6 @@ using AcDream.App.Rendering.Wb;
using AcDream.Core.Rendering;
using AcDream.Core.Vfx;
using AcDream.Core.World;
using Silk.NET.OpenGL;
using Silk.NET.Windowing;
namespace AcDream.App.Rendering;

View file

@ -1,103 +0,0 @@
using System;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Two persistent GL sampler objects (Repeat + ClampToEdge) created once
/// per GL context. Renderers <see cref="GL.BindSampler"/> the appropriate
/// one to a texture unit instead of mutating per-texture
/// <c>GL_TEXTURE_WRAP_S/T</c> state — sampler state overrides the
/// texture's own wrap parameters, so two renderers can share the same
/// texture handle but sample it with different wrap modes safely.
///
/// <para>
/// Ported from
/// <c>references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132</c>.
/// Filter modes match <see cref="TextureCache"/>'s upload defaults
/// (Linear / Linear, no mipmaps) so binding either sampler doesn't
/// change the visual filtering behavior — only the wrap behavior at
/// UVs outside [0, 1].
/// </para>
///
/// <para>
/// Lifetime: created once at GL init, disposed with the GL context.
/// Anything that binds a sampler MUST unbind it (<c>BindSampler(unit, 0)</c>)
/// before yielding to a renderer that doesn't use samplers, otherwise
/// the bound sampler's wrap mode will silently override that renderer's
/// per-texture wrap state.
/// </para>
/// </summary>
public sealed class SamplerCache : IDisposable
{
private readonly GL _gl;
private readonly ResourceCleanupGroup _resources;
/// <summary>Sampler with WrapS = WrapT = Repeat. The default for textures uploaded by <see cref="TextureCache"/>.</summary>
public uint Wrap { get; }
/// <summary>Sampler with WrapS = WrapT = ClampToEdge. Used by sky meshes whose authored UVs are strictly in [0, 1] to avoid bilinear-filter bleed at seam edges.</summary>
public uint Clamp { get; }
public SamplerCache(GL gl)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
var resources = new ResourceCleanupGroup();
uint wrap = 0;
uint clamp = 0;
try
{
wrap = GlResourceCommand.CreateName(
_gl,
"repeat sampler",
_gl.GenSampler,
_gl.DeleteSampler);
uint ownedWrap = wrap;
resources.Add(
"repeat sampler",
() => GlResourceCommand.Execute(
_gl,
$"delete repeat sampler {ownedWrap}",
() => _gl.DeleteSampler(ownedWrap)));
Configure(wrap, TextureWrapMode.Repeat, "repeat sampler");
clamp = GlResourceCommand.CreateName(
_gl,
"clamp sampler",
_gl.GenSampler,
_gl.DeleteSampler);
uint ownedClamp = clamp;
resources.Add(
"clamp sampler",
() => GlResourceCommand.Execute(
_gl,
$"delete clamp sampler {ownedClamp}",
() => _gl.DeleteSampler(ownedClamp)));
Configure(clamp, TextureWrapMode.ClampToEdge, "clamp sampler");
}
catch (Exception constructionFailure)
{
resources.RollbackConstructionAndThrow(
"SamplerCache construction failed and its sampler prefix did not cleanly roll back.",
constructionFailure);
}
Wrap = wrap;
Clamp = clamp;
_resources = resources;
}
private void Configure(uint sampler, TextureWrapMode wrap, string name) =>
GlResourceCommand.Execute(_gl, $"configure {name}", () =>
{
_gl.SamplerParameter(sampler, SamplerParameterI.WrapS, (int)wrap);
_gl.SamplerParameter(sampler, SamplerParameterI.WrapT, (int)wrap);
_gl.SamplerParameter(sampler, SamplerParameterI.MinFilter, (int)TextureMinFilter.Linear);
_gl.SamplerParameter(sampler, SamplerParameterI.MagFilter, (int)TextureMagFilter.Linear);
});
public void Dispose()
{
_resources.RetryCleanup();
}
}

View file

@ -1,61 +1,39 @@
using System;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Lighting;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// GL wrapper that owns the SceneLighting UBO buffer, updates its
/// contents each frame, and keeps it bound at binding=1 so every
/// shader sampling <c>uLights[]</c> / <c>uFogColor</c> / etc reads
/// consistent data without per-shader re-upload.
/// Publishes the SceneLighting UBO each frame so every shader sampling
/// <c>uLights[]</c> / <c>uFogColor</c> / etc reads consistent data without
/// per-shader re-upload.
///
/// <para>
/// Usage (r12 §13.2 + r13 §12.3):
/// <list type="number">
/// <item><description>Instantiate once at startup, after the GL context exists.</description></item>
/// <item><description>Each frame, after <see cref="LightManager.Tick"/>, call <see cref="Upload"/> with a freshly-built <see cref="SceneLightingUbo"/>.</description></item>
/// <item><description>Shader programs that declare <c>layout(std140, binding = 1) uniform SceneLighting { ... }</c> automatically pick up the data.</description></item>
/// </list>
/// </para>
/// <para>The raw-GL arm this used to have alongside it — a global uniform
/// binding point kept live by a per-flight-slot buffer pool — was deleted at
/// Campaign V slice V11. Vulkan has no such global binding point: a
/// descriptor set is bound per draw, so the upload is a frame ring slice
/// PUBLISHED on <see cref="WorldFrameSections.SceneLighting"/>, and each world
/// renderer binds it inside the pass after its own binds (plan §5.5.14 item
/// 2). Every allocation within a frame is already distinct memory that lives
/// until the frame retires, which is the property the deleted buffer pool
/// existed to provide when the world, portal-space and paperdoll draws each
/// upload different lighting.</para>
/// </summary>
public sealed unsafe class SceneLightingUboBinding : IDisposable
{
private readonly GL? _gl;
private readonly ICurrentGpuFrameSource? _frames;
private readonly WorldFrameSections? _sections;
private uint _ubo;
private readonly List<uint>[] _buffersByFrame = [[], [], []];
private int _frameSlot;
private int _bufferCursor;
private readonly ICurrentGpuFrameSource _frames;
private readonly WorldFrameSections _sections;
private bool _frameStarted;
private bool _disposed;
internal int DynamicBufferCount => _buffersByFrame.Sum(buffers => buffers.Count);
public SceneLightingUboBinding(GL gl)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
}
/// <summary>
/// Campaign V slice V6j: the RHI arm.
///
/// <para>GL keeps the block bound at a global uniform binding point and every
/// shader inherits it. Vulkan has no such point — a descriptor set is bound
/// per draw and the renderer's own binds select the scope this block has to
/// land in — so the upload becomes a frame ring slice PUBLISHED on
/// <see cref="WorldFrameSections.SceneLighting"/>, and each world renderer
/// binds it inside the pass after its own binds (plan §5.5.14 item 2).</para>
///
/// <para>The per-flight-slot buffer pool disappears with it: every allocation
/// within a frame is already distinct memory that lives until the frame
/// retires, which is the property the pool existed to provide when the world,
/// portal-space and paperdoll draws each upload different lighting.</para>
/// The GL arm's per-flight-slot buffer pool this used to report on was
/// deleted at Campaign V slice V11: every allocation now comes from a ring
/// that lives until its frame retires, so there is no persistent
/// dynamic-buffer pool left to size.
/// </summary>
internal int DynamicBufferCount => 0;
internal SceneLightingUboBinding(
ICurrentGpuFrameSource frames,
WorldFrameSections sections)
@ -71,80 +49,20 @@ public sealed unsafe class SceneLightingUboBinding : IDisposable
/// </summary>
public void BeginFrame(int frameSlot)
{
if ((uint)frameSlot >= (uint)_buffersByFrame.Length)
throw new ArgumentOutOfRangeException(nameof(frameSlot));
_frameSlot = frameSlot;
_bufferCursor = 0;
ArgumentOutOfRangeException.ThrowIfNegative(frameSlot);
_frameStarted = true;
}
private void ActivateNextBuffer()
{
if (!_frameStarted)
throw new InvalidOperationException("BeginFrame must be called before uploading scene lighting.");
if (_gl is null)
throw new InvalidOperationException("The RHI arm publishes a ring section rather than a buffer.");
List<uint> buffers = _buffersByFrame[_frameSlot];
if (_bufferCursor == buffers.Count)
{
uint buffer = TrackedGlResource.CreateBuffer(
_gl,
"SceneLighting frame UBO creation");
try
{
TrackedGlResource.AllocateBufferStorage(
_gl,
GLEnum.UniformBuffer,
buffer,
0,
SceneLightingUbo.SizeInBytes,
GLEnum.DynamicDraw,
"SceneLighting frame UBO allocation");
buffers.Add(buffer);
}
catch
{
TrackedGlResource.DeleteBuffer(
_gl,
buffer,
0,
"SceneLighting frame UBO rollback");
throw;
}
}
_ubo = buffers[_bufferCursor++];
}
/// <summary>
/// Push the current frame's UBO contents to the GPU. Cheap (576 bytes)
/// so fine to call unconditionally every frame.
/// </summary>
public void Upload(SceneLightingUbo data)
{
if (_gl is null)
{
PublishSection(data);
return;
}
ActivateNextBuffer();
_gl.BindBuffer(BufferTargetARB.UniformBuffer, _ubo);
_gl.BufferSubData(BufferTargetARB.UniformBuffer,
(nint)0, (nuint)SceneLightingUbo.SizeInBytes, &data);
_gl.BindBufferBase(BufferTargetARB.UniformBuffer,
SceneLightingUbo.BindingPoint, _ubo);
_gl.BindBuffer(BufferTargetARB.UniformBuffer, 0);
}
private void PublishSection(SceneLightingUbo data)
{
if (!_frameStarted)
throw new InvalidOperationException("BeginFrame must be called before uploading scene lighting.");
IGpuFrame frame = _frames!.CurrentFrame
IGpuFrame frame = _frames.CurrentFrame
?? throw new InvalidOperationException(
"Scene lighting requires an open IGpuFrame (see GpuDeviceFrameLifetime).");
GpuRingAllocation allocation = frame.AllocateRing(
@ -152,30 +70,19 @@ public sealed unsafe class SceneLightingUboBinding : IDisposable
GpuRingUsage.Uniform);
new ReadOnlySpan<byte>(&data, SceneLightingUbo.SizeInBytes)
.CopyTo(allocation.Data);
_sections!.SceneLighting = new GpuBufferSection(
_sections.SceneLighting = new GpuBufferSection(
allocation.Buffer,
allocation.OffsetBytes,
(uint)SceneLightingUbo.SizeInBytes);
}
/// <summary>
/// No-op: this binding owns no GPU resource of its own on the RHI arm — the
/// deleted GL arm's per-flight buffer pool was the only thing to release.
/// Kept as a method so callers that hold this behind an <see cref="IDisposable"/>
/// reference (composition's acquisition scope) don't need a special case.
/// </summary>
public void Dispose()
{
if (_disposed) return;
if (_gl is not null)
{
foreach (List<uint> buffers in _buffersByFrame)
{
foreach (uint buffer in buffers)
{
TrackedGlResource.DeleteBuffer(
_gl,
buffer,
SceneLightingUbo.SizeInBytes,
"SceneLighting frame UBO disposal");
}
buffers.Clear();
}
}
_disposed = true;
}
}

View file

@ -1,143 +0,0 @@
using System.Numerics;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
public sealed class Shader : IDisposable
{
private readonly GL _gl;
private readonly Dictionary<string, int> _uniformLocations = new(StringComparer.Ordinal);
public uint Program { get; private set; }
public Shader(GL gl, string vertexPath, string fragmentPath)
: this(gl, vertexPath, fragmentPath, includeCommonPreamble: false)
{
}
/// <summary>
/// Campaign V slice V2 (docs/plans/2026-07-27-vulkan-campaign.md §3.4): when
/// <paramref name="includeCommonPreamble"/> is true, the text of
/// <c>Shaders/common.glsl</c> — sitting alongside <paramref name="vertexPath"/>
/// — is spliced into both sources right after their leading
/// <c>#version</c>/<c>#extension</c> block. GL has no <c>#include</c>, so this
/// is plain string concatenation at load time rather than a GLSL-level
/// mechanism. Every existing two-argument-path caller is unaffected: the
/// convenience constructor above always passes false.
/// </summary>
public Shader(GL gl, string vertexPath, string fragmentPath, bool includeCommonPreamble)
{
_gl = gl;
string vertexSource = File.ReadAllText(vertexPath);
string fragmentSource = File.ReadAllText(fragmentPath);
if (includeCommonPreamble)
{
string? directory = Path.GetDirectoryName(vertexPath);
string commonPath = directory is null
? "common.glsl"
: Path.Combine(directory, "common.glsl");
string commonSource = File.ReadAllText(commonPath);
vertexSource = InjectPreamble(vertexSource, commonSource);
fragmentSource = InjectPreamble(fragmentSource, commonSource);
}
Program = ShaderProgramConstruction.Build(
new GlShaderProgramBuildApi(gl),
vertexSource,
fragmentSource);
}
/// <summary>
/// Inserts <paramref name="preamble"/> right after the shader's leading
/// <c>#version</c>/<c>#extension</c>/blank-line block. GLSL requires
/// <c>#version</c> to be the very first statement in the source, so the
/// preamble cannot simply be prepended — it has to land after that block,
/// before the first real declaration.
///
/// Internal rather than private since slice V6d: <c>GlGpuDevice.CreatePipeline</c>
/// loads RHI shader pairs itself and has to splice the same preamble the
/// same way. Two implementations of "where does the preamble go" is exactly
/// the kind of drift that shows up as one shader silently missing a binding.
/// </summary>
internal static string InjectPreamble(string source, string preamble)
{
int insertAt = 0;
int lineStart = 0;
while (lineStart < source.Length)
{
int lineEnd = source.IndexOf('\n', lineStart);
if (lineEnd < 0)
lineEnd = source.Length;
string trimmed = source[lineStart..lineEnd].TrimStart();
bool isLeadingLine =
trimmed.Length == 0
|| trimmed.StartsWith("#version", StringComparison.Ordinal)
|| trimmed.StartsWith("#extension", StringComparison.Ordinal);
if (!isLeadingLine)
break;
insertAt = lineEnd < source.Length ? lineEnd + 1 : lineEnd;
lineStart = lineEnd + 1;
}
return string.Concat(source.AsSpan(0, insertAt), preamble, "\n", source.AsSpan(insertAt));
}
public void Use() => _gl.UseProgram(Program);
public unsafe void SetMatrix4(string name, Matrix4x4 m)
{
int loc = GetUniformLocation(name);
_gl.UniformMatrix4(loc, 1, false, (float*)&m);
}
public void SetInt(string name, int value)
{
int loc = GetUniformLocation(name);
_gl.Uniform1(loc, value);
}
public void SetFloat(string name, float value)
{
int loc = GetUniformLocation(name);
_gl.Uniform1(loc, value);
}
public void SetVec3(string name, Vector3 v)
{
int loc = GetUniformLocation(name);
_gl.Uniform3(loc, v.X, v.Y, v.Z);
}
public void SetVec2(string name, Vector2 v)
{
int loc = GetUniformLocation(name);
_gl.Uniform2(loc, v.X, v.Y);
}
public void SetVec4(string name, Vector4 v)
{
int loc = GetUniformLocation(name);
_gl.Uniform4(loc, v.X, v.Y, v.Z, v.W);
}
private int GetUniformLocation(string name)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
if (_uniformLocations.TryGetValue(name, out int location))
return location;
location = _gl.GetUniformLocation(Program, name);
_uniformLocations.Add(name, location);
return location;
}
public void Dispose()
{
uint program = Program;
if (program == 0)
return;
_uniformLocations.Clear();
GlResourceCommand.DeleteProgram(_gl, program, $"delete Shader program {program}");
Program = 0;
}
}

View file

@ -1,325 +0,0 @@
using System.Runtime.ExceptionServices;
using AcDream.App.Rendering.Wb;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
internal interface IShaderProgramBuildApi
{
uint CreateShader(ShaderType type);
void ShaderSource(uint shader, string source);
void CompileShader(uint shader);
int GetShaderCompileStatus(uint shader);
string GetShaderInfoLog(uint shader);
uint CreateProgram();
void AttachShader(uint program, uint shader);
void LinkProgram(uint program);
int GetProgramLinkStatus(uint program);
string GetProgramInfoLog(uint program);
void DetachShader(uint program, uint shader);
void DeleteShader(uint shader);
void DeleteProgram(uint program);
}
internal sealed class GlShaderProgramBuildApi(GL gl) : IShaderProgramBuildApi
{
public uint CreateShader(ShaderType type) =>
GlResourceCommand.CreateName(
gl,
$"{type} shader",
() => gl.CreateShader(type),
gl.DeleteShader);
public void ShaderSource(uint shader, string source) =>
GlResourceCommand.Execute(
gl,
$"upload shader source {shader}",
() => gl.ShaderSource(shader, source));
public void CompileShader(uint shader) =>
GlResourceCommand.Execute(
gl,
$"compile shader {shader}",
() => gl.CompileShader(shader));
public void GetShader(uint shader, ShaderParameterName name, out int value) =>
gl.GetShader(shader, name, out value);
public int GetShaderCompileStatus(uint shader)
{
return GlResourceCommand.Execute(
gl,
$"read shader {shader} compile status",
() =>
{
gl.GetShader(shader, ShaderParameterName.CompileStatus, out int value);
return value;
});
}
public string GetShaderInfoLog(uint shader) =>
GlResourceCommand.Execute(
gl,
$"read shader {shader} info log",
() => gl.GetShaderInfoLog(shader));
public uint CreateProgram() =>
GlResourceCommand.CreateName(
gl,
"shader program",
gl.CreateProgram,
gl.DeleteProgram);
public void AttachShader(uint program, uint shader) =>
GlResourceCommand.Execute(
gl,
$"attach shader {shader} to program {program}",
() => gl.AttachShader(program, shader));
public void LinkProgram(uint program) =>
GlResourceCommand.Execute(
gl,
$"link shader program {program}",
() => gl.LinkProgram(program));
public int GetProgramLinkStatus(uint program)
{
return GlResourceCommand.Execute(
gl,
$"read shader program {program} link status",
() =>
{
gl.GetProgram(program, ProgramPropertyARB.LinkStatus, out int value);
return value;
});
}
public string GetProgramInfoLog(uint program) =>
GlResourceCommand.Execute(
gl,
$"read shader program {program} info log",
() => gl.GetProgramInfoLog(program));
public void DetachShader(uint program, uint shader) =>
GlResourceCommand.Execute(
gl,
$"detach shader {shader} from program {program}",
() => gl.DetachShader(program, shader));
public void DeleteShader(uint shader) =>
GlResourceCommand.DeleteShader(gl, shader, $"delete shader {shader}");
public void DeleteProgram(uint program) =>
GlResourceCommand.DeleteProgram(gl, program, $"delete shader program {program}");
}
internal sealed class ShaderProgramCleanup(IShaderProgramBuildApi api)
: IRetryableResourceCleanup
{
public uint Vertex { get; set; }
public uint Fragment { get; set; }
public uint Program { get; set; }
public bool VertexAttached { get; set; }
public bool FragmentAttached { get; set; }
public bool IsCleanupComplete => Vertex == 0 && Fragment == 0 && Program == 0;
public uint TransferProgram()
{
if (Vertex != 0 || Fragment != 0 || Program == 0)
throw new InvalidOperationException("The linked program is not ready for ownership transfer.");
uint program = Program;
Program = 0;
return program;
}
public List<Exception> Release(bool includeProgram)
{
var failures = new List<Exception>();
Try(
() =>
{
if (Program != 0 && Vertex != 0 && VertexAttached)
{
api.DetachShader(Program, Vertex);
VertexAttached = false;
}
},
failures);
Try(
() =>
{
if (Program != 0 && Fragment != 0 && FragmentAttached)
{
api.DetachShader(Program, Fragment);
FragmentAttached = false;
}
},
failures);
Try(
() =>
{
if (Vertex != 0)
{
api.DeleteShader(Vertex);
Vertex = 0;
VertexAttached = false;
}
},
failures);
Try(
() =>
{
if (Fragment != 0)
{
api.DeleteShader(Fragment);
Fragment = 0;
FragmentAttached = false;
}
},
failures);
if (includeProgram)
{
Try(
() =>
{
if (Program != 0)
{
api.DeleteProgram(Program);
Program = 0;
VertexAttached = false;
FragmentAttached = false;
}
},
failures);
}
return failures;
}
public List<Exception> ReleaseProgramOnly()
{
var failures = new List<Exception>();
Try(
() =>
{
if (Program != 0)
{
api.DeleteProgram(Program);
Program = 0;
VertexAttached = false;
FragmentAttached = false;
}
},
failures);
return failures;
}
public void RetryCleanup()
{
List<Exception> failures = Release(includeProgram: true);
if (!IsCleanupComplete)
{
throw new AggregateException(
"Shader program cleanup remains incomplete.",
failures.Count == 0
? [new InvalidOperationException("Shader cleanup retained names without reporting an operation failure.")]
: failures);
}
}
private static void Try(Action operation, List<Exception> failures)
{
try
{
operation();
}
catch (Exception failure)
{
failures.Add(failure);
}
}
}
internal static class ShaderProgramConstruction
{
public static uint Build(
IShaderProgramBuildApi api,
string vertexSource,
string fragmentSource)
{
ArgumentNullException.ThrowIfNull(api);
ArgumentNullException.ThrowIfNull(vertexSource);
ArgumentNullException.ThrowIfNull(fragmentSource);
var cleanup = new ShaderProgramCleanup(api);
Exception? constructionFailure = null;
try
{
cleanup.Vertex = CreateShader(api, ShaderType.VertexShader);
Compile(api, cleanup.Vertex, ShaderType.VertexShader, vertexSource);
cleanup.Fragment = CreateShader(api, ShaderType.FragmentShader);
Compile(api, cleanup.Fragment, ShaderType.FragmentShader, fragmentSource);
cleanup.Program = api.CreateProgram();
if (cleanup.Program == 0)
throw new InvalidOperationException("OpenGL returned no shader program name.");
api.AttachShader(cleanup.Program, cleanup.Vertex);
cleanup.VertexAttached = true;
api.AttachShader(cleanup.Program, cleanup.Fragment);
cleanup.FragmentAttached = true;
api.LinkProgram(cleanup.Program);
if (api.GetProgramLinkStatus(cleanup.Program) == 0)
throw new InvalidOperationException(
"program link failed: " + api.GetProgramInfoLog(cleanup.Program));
}
catch (Exception failure)
{
constructionFailure = failure;
}
List<Exception> cleanupFailures = cleanup.Release(includeProgram: false);
if (constructionFailure is null && cleanupFailures.Count == 0)
return cleanup.TransferProgram();
cleanupFailures.AddRange(cleanup.ReleaseProgramOnly());
if (constructionFailure is not null && cleanupFailures.Count == 0)
ExceptionDispatchInfo.Capture(constructionFailure).Throw();
var failures = new List<Exception>(cleanupFailures.Count + 1);
if (constructionFailure is not null)
failures.Add(constructionFailure);
failures.AddRange(cleanupFailures);
const string message =
"Shader program construction failed and one or more temporary OpenGL names could not be cleanly released.";
if (!cleanup.IsCleanupComplete)
throw new GlResourceConstructionException(message, cleanup, failures);
throw new AggregateException(message, failures);
}
private static uint CreateShader(
IShaderProgramBuildApi api,
ShaderType type)
{
uint shader = api.CreateShader(type);
if (shader == 0)
throw new InvalidOperationException($"OpenGL returned no {type} name.");
return shader;
}
private static void Compile(
IShaderProgramBuildApi api,
uint shader,
ShaderType type,
string source)
{
api.ShaderSource(shader, source);
api.CompileShader(shader);
if (api.GetShaderCompileStatus(shader) == 0)
throw new InvalidOperationException(
$"{type} compile failed: " + api.GetShaderInfoLog(shader));
}
}

View file

@ -9,7 +9,6 @@ using DatReaderWriter;
using AcDream.Content;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering.Sky;
@ -43,46 +42,11 @@ namespace AcDream.App.Rendering.Sky;
/// measured clockwise from north.
/// </para>
/// </summary>
public sealed unsafe partial class SkyRenderer : IDisposable
public sealed partial class SkyRenderer : IDisposable
{
private readonly GL? _gl;
private readonly IDatReaderWriter _dats;
private readonly Shader? _shader;
private readonly TextureCache _textures;
private readonly SamplerCache? _samplers;
// Campaign V slice V6e. Two GL objects replace what used to be a run of
// glUniform* calls and a texture/sampler binding on unit 0:
//
// _paramsUbo — the std140 SkyParams block both stages declare.
// _uTextureIndexALoc — the push-constant-named uniform carrying the slot.
//
// The wrap mode is what makes the (texture, wrap) pairing necessary. A
// bindless handle BAKES its sampler state, so the per-submesh
// Repeat-vs-ClampToEdge choice that used to be a glBindSampler call has to be
// a different handle — which is also exactly how the Vulkan table works,
// where an entry is a combined image sampler. ManagedGLTextureArray has
// interned wrap/clamp handle pairs the same way since the world path went
// bindless.
//
// Campaign V slice V6k retired the interim per-renderer GlBindlessHandleTable
// — the last one in the tree — for the device's own retirement-gated table,
// exactly as V4t did for the other four world renderers. The handles are
// still minted here, because the sky is the one world path whose textures it
// produces itself; only the slot allocator moved.
private readonly Wb.BindlessSupport? _bindless;
/// <summary>
/// Campaign V slice V6k: the GL arm's slot allocator. The sky mints its own
/// resident handles — it is the one world path that does — so it registers
/// them into the device's table through V4t's world-handle seam instead of
/// keeping the last private <c>GlBindlessHandleTable</c> in the tree.
/// </summary>
private readonly AcDream.App.Rendering.Gpu.Gl.GlGpuDevice? _glDevice;
private readonly Dictionary<(uint Texture, bool Repeat), ulong> _handleByTextureAndWrap = new();
private uint _paramsUbo;
private int _uTextureIndexALoc = -1;
private SkyParams _params;
// Lazily-built GPU resources per sky-GfxObj.
@ -125,40 +89,6 @@ public sealed unsafe partial class SkyRenderer : IDisposable
public float Near { get; set; } = 0.1f;
public float Far { get; set; } = 1_000_000f;
internal SkyRenderer(
GL gl,
IDatReaderWriter dats,
Shader shader,
TextureCache textures,
SamplerCache samplers,
Wb.BindlessSupport bindless,
AcDream.App.Rendering.Gpu.Gl.GlGpuDevice device)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
_shader = shader ?? throw new ArgumentNullException(nameof(shader));
_textures = textures ?? throw new ArgumentNullException(nameof(textures));
_samplers = samplers ?? throw new ArgumentNullException(nameof(samplers));
_bindless = bindless ?? throw new ArgumentNullException(nameof(bindless));
_glDevice = device ?? throw new ArgumentNullException(nameof(device));
_uTextureIndexALoc = _gl.GetUniformLocation(_shader.Program, "uTextureIndexA");
_paramsUbo = Wb.TrackedGlResource.CreateBuffer(_gl, "sky params UBO creation");
// Fixed size: SkyParams is a 256-byte std140 struct, so the buffer never
// grows and every write is one whole-struct BufferSubData.
_gl.BindBuffer(BufferTargetARB.UniformBuffer, _paramsUbo);
Wb.TrackedGlResource.AllocateBufferStorage(
_gl,
BufferTargetARB.UniformBuffer,
_paramsUbo,
0,
SkyParams.SizeInBytes,
BufferUsageARB.DynamicDraw,
"allocating sky params UBO");
_gl.BindBuffer(BufferTargetARB.UniformBuffer, 0);
}
/// <summary>
/// Draw all NON-WEATHER sky objects (dome, sun, moon, stars, clouds —
/// every <c>SkyObject</c> with <c>Properties &amp; 0x04 == 0</c>).
@ -265,7 +195,6 @@ public sealed unsafe partial class SkyRenderer : IDisposable
skyView.M42 = 0f;
skyView.M43 = 0f;
_shader?.Use();
// Campaign V slice V6e: the values below used to be individual
// glUniform* calls. They now populate the std140 SkyParams block, which
// is uploaded once per submesh right before its draw — the same cadence
@ -283,34 +212,6 @@ public sealed unsafe partial class SkyRenderer : IDisposable
_params.SunDir =
AcDream.Core.World.SkyStateProvider.SunDirectionFromKeyframe(keyframe);
bool wasCullFace = false;
if (_gl is { } gl)
{
gl.BindBufferBase(
BufferTargetARB.UniformBuffer,
AcDream.App.Rendering.Gpu.GpuBindingModel.UniformSkyParams,
_paramsUbo);
// Save + override GL state.
gl.DepthMask(false);
gl.Disable(EnableCap.DepthTest);
// Save + disable CullFace for the sky pass; restore at the end.
// Mirrors TextRenderer.cs's save/restore pattern. Without this the
// sky pass left CullFace disabled regardless of its prior state,
// which is benign today (the global convention in this codebase is
// off and subsequent renderers manage their own CullFace) but
// would break the moment any future caller assumes back-face
// culling stays on across the sky pass.
wasCullFace = gl.IsEnabled(EnableCap.CullFace);
gl.Disable(EnableCap.CullFace);
gl.Enable(EnableCap.Blend);
// Default blend — overridden per-submesh inside the inner loop.
// Additive surfaces (sun/moon/stars via SurfaceType.Additive =
// 0x10000) get GL_SRC_ALPHA / GL_ONE; alpha-blended (clouds, dome
// with Alpha flag) get GL_SRC_ALPHA / GL_ONE_MINUS_SRC_ALPHA.
gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
}
// Look up the keyframe's override list so we can apply
// SkyObjReplace (r12 §2.3): per-keyframe GfxObj swaps + rotation
// override + transparency fade + luminosity cap.
@ -434,14 +335,6 @@ public sealed unsafe partial class SkyRenderer : IDisposable
// mesh pipeline where Surface flags dictate state. On the RHI
// arm the same two blend functions are two pipelines, because
// Vulkan bakes blend rather than making it dynamic.
if (_gl is { } blendGl)
{
if (sub.IsAdditive)
blendGl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One);
else
blendGl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
}
// Emissive source picks the surface's authored Luminosity by
// default; the per-keyframe replace data can OVERRIDE
// (rep.Luminosity > 0) or CAP (rep.MaxBright). This matches
@ -532,46 +425,9 @@ public sealed unsafe partial class SkyRenderer : IDisposable
|| obj.TexVelocityX != 0f
|| obj.TexVelocityY != 0f;
uint slot = TextureTableSlot(sub.SurfaceId, needsRepeat);
if (_gl is { } drawGl)
{
drawGl.ProgramUniform1(
_shader!.Program,
_uTextureIndexALoc,
slot);
UploadParams();
FlushAndBindTextureTable();
drawGl.BindVertexArray(sub.Vao);
drawGl.DrawElements(PrimitiveType.Triangles,
(uint)sub.IndexCount,
DrawElementsType.UnsignedInt,
(void*)0);
}
else
{
DrawSubMeshRhi(sub, slot);
}
DrawSubMeshRhi(sub, slot);
}
}
// Restore GL state expected by the rest of the pipeline.
//
// Slice V6e removed the glBindSampler(0, …) that used to happen here
// and its matching unbind. The sky no longer touches texture unit 0 at
// all — its wrap mode rides inside the bindless handle — so there is
// nothing left to leak onto the next renderer's unit 0. That unbind was
// load-bearing precisely because the binding was global state; a table
// slot is not.
if (_gl is { } restoreGl)
{
restoreGl.Disable(EnableCap.Blend);
restoreGl.DepthMask(true);
restoreGl.Enable(EnableCap.DepthTest);
if (wasCullFace) restoreGl.Enable(EnableCap.CullFace);
restoreGl.BindVertexArray(0);
}
}
/// <summary>
@ -588,57 +444,8 @@ public sealed unsafe partial class SkyRenderer : IDisposable
/// handle table in the codebase — the sky's texture set is a fixed handful
/// per day group and does not churn.</para>
/// </summary>
private uint TextureTableSlot(uint surfaceId, bool repeat)
{
if (_gl is null)
return RhiTextureTableSlot(surfaceId, repeat);
uint textureName = _textures.GetOrUpload(surfaceId);
var key = (textureName, repeat);
if (!_handleByTextureAndWrap.TryGetValue(key, out ulong handle))
{
handle = _bindless!.GetResidentHandle(
textureName,
repeat ? _samplers!.Wrap : _samplers!.Clamp);
_handleByTextureAndWrap.Add(key, handle);
}
return _glDevice!.RegisterWorldTextureHandle(handle).Index;
}
/// <summary>
/// Writes the current <see cref="SkyParams"/> to its uniform buffer. Called
/// immediately before each draw, which is the cadence the per-submesh
/// glUniform* calls it replaces already had.
/// </summary>
private void UploadParams()
{
GL gl = _gl!;
fixed (void* p = &_params)
{
gl.BindBuffer(BufferTargetARB.UniformBuffer, _paramsUbo);
gl.BufferSubData(
BufferTargetARB.UniformBuffer, 0, (nuint)SkyParams.SizeInBytes, p);
}
}
/// <summary>
/// Campaign V slice V6k: drains the device texture table's dirty runs and
/// (re)binds it at
/// <see cref="AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable"/>.
/// Mirrors <c>ParticleRenderer.FlushAndBindTextureTable</c>, including its
/// reason for running per draw rather than per pass: a submesh partway
/// through the sky stack can be the first to ask for a wrap mode.
/// </summary>
private void FlushAndBindTextureTable()
{
AcDream.App.Rendering.Gpu.Gl.GlGpuDevice device = _glDevice!;
device.FlushTextureTable();
_gl!.BindBufferBase(
BufferTargetARB.ShaderStorageBuffer,
AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable,
device.TextureTableGlName);
}
private uint TextureTableSlot(uint surfaceId, bool repeat) =>
RhiTextureTableSlot(surfaceId, repeat);
/// <summary>
/// Find the <see cref="SkyObjectReplaceData"/> entries for the
@ -865,106 +672,9 @@ public sealed unsafe partial class SkyRenderer : IDisposable
}
}
private SubMeshGpu UploadSubMesh(GfxObjSubMesh sm)
{
if (_gl is null)
return UploadSubMeshRhi(sm);
private SubMeshGpu UploadSubMesh(GfxObjSubMesh sm) => UploadSubMeshRhi(sm);
uint vao = _gl.GenVertexArray();
_gl.BindVertexArray(vao);
uint vbo = _gl.GenBuffer();
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, vbo);
fixed (void* p = sm.Vertices)
_gl.BufferData(BufferTargetARB.ArrayBuffer,
(nuint)(sm.Vertices.Length * sizeof(Vertex)), p, BufferUsageARB.StaticDraw);
uint ebo = _gl.GenBuffer();
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, ebo);
fixed (void* p = sm.Indices)
_gl.BufferData(BufferTargetARB.ElementArrayBuffer,
(nuint)(sm.Indices.Length * sizeof(uint)), p, BufferUsageARB.StaticDraw);
uint stride = (uint)sizeof(Vertex);
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
_gl.EnableVertexAttribArray(2);
_gl.VertexAttribPointer(2, 2, VertexAttribPointerType.Float, false, stride, (void*)(6 * sizeof(float)));
_gl.BindVertexArray(0);
// Classify blend mode from the Surface's flags. Sun/moon/stars with
// `SurfaceType.Additive = 0x10000` get GL_ONE / GL_ONE (their texture
// has a black background and a bright body; additive makes the
// background contribute nothing and the body glow on top of the sky).
//
// NOTE: earlier revision also treated `SurfaceType.Luminous = 0x40`
// as additive, but that flag is present on the sky DOME itself and
// on cloud sheets — turning those additive blew the whole sky to
// white. `Luminous` means "self-illuminated / unshaded" in retail's
// render pipeline, not "additive blend". Only the Additive bit
// toggles the blend mode.
bool isAdditive = sm.Translucency == TranslucencyKind.Additive;
return new SubMeshGpu
{
Vao = vao,
Vbo = vbo,
Ebo = ebo,
IndexCount = sm.Indices.Length,
SurfaceId = sm.SurfaceId,
IsAdditive = isAdditive,
SurfLuminosity = sm.Luminosity,
SurfDiffuse = sm.Diffuse,
NeedsUvRepeat = sm.NeedsUvRepeat,
SurfOpacity = sm.SurfOpacity,
DisableFog = sm.DisableFog,
};
}
public void Dispose()
{
if (_gl is null)
{
DisposeRhi();
return;
}
foreach (var subs in _gpuByGfxObj.Values)
{
foreach (var sub in subs)
{
_gl.DeleteBuffer(sub.Vbo);
_gl.DeleteBuffer(sub.Ebo);
_gl.DeleteVertexArray(sub.Vao);
}
}
_gpuByGfxObj.Clear();
// Campaign V slice V6e. Residency first: a handle made resident pins
// its texture's GPU address, and the textures themselves belong to
// TextureCache, which outlives this renderer and disposes them itself.
//
// Slice V6k: the device's table entry is retired alongside it, in the
// order V4t established — the table entry goes first, because the slot
// is only recycled once the frames that could still read it retire,
// whereas the handle stops being valid the instant it is non-resident.
foreach (ulong handle in _handleByTextureAndWrap.Values)
{
_glDevice!.ReleaseWorldTextureHandle(handle);
_bindless!.MakeNonResident(handle);
}
_handleByTextureAndWrap.Clear();
if (_paramsUbo != 0)
{
Wb.TrackedGlResource.DeleteBuffer(
_gl, _paramsUbo, SkyParams.SizeInBytes, "sky params UBO disposal");
_paramsUbo = 0;
}
}
public void Dispose() => DisposeRhi();
/// <summary>
/// Campaign V slice V6e: the CPU mirror of sky.{vert,frag}'s <c>SkyParams</c>
@ -999,14 +709,11 @@ public sealed unsafe partial class SkyRenderer : IDisposable
private sealed class SubMeshGpu
{
public uint Vao;
public uint Vbo;
public uint Ebo;
/// <summary>
/// Campaign V slice V6k: the RHI arm's vertex source. The sky's meshes are
/// built once per GfxObj and never change, so each submesh owns a
/// device-local buffer pair rather than taking a ring slice per frame.
/// Null on the GL arm, which uses <see cref="Vao"/>.
/// The raw-GL arm's VAO/VBO/EBO names were deleted at slice V11.
/// </summary>
public AcDream.App.Rendering.Gpu.IGpuBuffer? VertexBuffer;
public AcDream.App.Rendering.Gpu.IGpuBuffer? IndexBuffer;

View file

@ -1,13 +1,10 @@
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Gl;
using AcDream.Core.Textures;
using DatReaderWriter;
using AcDream.Content;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using Silk.NET.OpenGL;
using DatPixelFormat = DatReaderWriter.Enums.PixelFormat;
using GLPixelFormat = Silk.NET.OpenGL.PixelFormat;
namespace AcDream.App.Rendering;
@ -29,12 +26,8 @@ namespace AcDream.App.Rendering;
/// The alpha atlas is built but not yet sampled by any shader — that wiring
/// lands in Phase 3c.4 along with the shader rewrite.
/// </summary>
public sealed unsafe class TerrainAtlas : IDisposable
public sealed class TerrainAtlas : IDisposable
{
private readonly GL? _gl;
// --- Terrain atlas (unchanged public API from Phase 2b) ---
public uint GlTexture { get; } // terrain atlas, kept as GlTexture for back-compat with TerrainRenderer
public IReadOnlyDictionary<uint, uint> TerrainTypeToLayer { get; }
public int LayerCount { get; }
/// <summary>
@ -45,7 +38,6 @@ public sealed unsafe class TerrainAtlas : IDisposable
public IReadOnlyList<float> TilingByLayer { get; }
// --- Alpha atlas (new in Phase 3c.2) ---
public uint GlAlphaTexture { get; }
public int AlphaLayerCount { get; }
/// <summary>Layer indices in the alpha atlas for CornerTerrainMaps (typically 4 entries).</summary>
public IReadOnlyList<byte> CornerAlphaLayers { get; }
@ -62,77 +54,12 @@ public sealed unsafe class TerrainAtlas : IDisposable
/// <summary>RCode for each RoadMap, parallel to <see cref="RoadAlphaLayers"/>.</summary>
public IReadOnlyList<uint> RoadAlphaRCodes { get; }
private readonly Wb.BindlessSupport? _bindless;
private readonly BindlessTexturePair? _bindlessHandles;
private readonly BindlessTextureMutationGuard? _bindlessMutation;
private readonly RestoredTextureBindingMutation _anisotropyBindingMutation = new();
private ResourceShutdownTransaction? _shutdown;
private ulong _registeredTerrainHandle;
private ulong _registeredAlphaHandle;
private GpuTextureSlot _terrainSlot = GpuTextureSlot.Unassigned;
private GpuTextureSlot _alphaSlot = GpuTextureSlot.Unassigned;
/// <summary>
/// Campaign V slice V4t: the device texture-table slots for the terrain and
/// alpha arrays — the backend-neutral replacement for the raw 64-bit
/// <c>ARB_bindless_texture</c> handles this used to return. Residency still
/// belongs to this atlas (acquired lazily here, released by
/// <see cref="Dispose"/>); the device owns only the two table entries.
///
/// <para>Throws <see cref="InvalidOperationException"/> if the atlas was
/// constructed without a <see cref="Wb.BindlessSupport"/> instance.</para>
///
/// <para><see cref="SetAnisotropic"/> makes both textures non-resident and
/// re-acquires them, which yields new handles. Re-registering is therefore
/// conditional on the handle actually having changed, and the superseded
/// table entry is retired in the same step — otherwise a quality-preset
/// change would leak a slot holding a non-resident handle. Registration is
/// idempotent, so the common per-draw call is two dictionary lookups.</para>
/// </summary>
internal (GpuTextureSlot Terrain, GpuTextureSlot Alpha) GetTextureSlots(GlGpuDevice device)
{
ArgumentNullException.ThrowIfNull(device);
if (_rhi is not null)
{
throw new InvalidOperationException(
"This TerrainAtlas owns IGpuTexture arrays; ask it for TextureSlots, not for GL handles.");
}
if (_bindless is null)
throw new InvalidOperationException(
"TerrainAtlas was constructed without BindlessSupport; cannot return texture slots.");
(ulong terrain, ulong alpha) = _bindlessHandles!.Acquire();
if (terrain != _registeredTerrainHandle)
{
device.ReleaseWorldTextureHandle(_registeredTerrainHandle);
_terrainSlot = device.RegisterWorldTextureHandle(terrain);
_registeredTerrainHandle = terrain;
}
if (alpha != _registeredAlphaHandle)
{
device.ReleaseWorldTextureHandle(_registeredAlphaHandle);
_alphaSlot = device.RegisterWorldTextureHandle(alpha);
_registeredAlphaHandle = alpha;
}
return (_terrainSlot, _alphaSlot);
}
/// <summary>
/// Campaign V slice V6i-2: the backend-neutral arm. When present, both
/// arrays are <see cref="IGpuTexture"/>s the device created and both slots
/// were registered at build time, so <see cref="TextureSlots"/> is a field
/// read rather than a residency negotiation. <see cref="GlTexture"/> and
/// <see cref="GlAlphaTexture"/> are 0 and no GL handle exists at all.
///
/// <para>V4t moved the TABLE ENTRY to the device but left CREATION with this
/// class, which is exactly the remainder plan §5.5.11 recorded: "Creating
/// world textures through IGpuTexture is real remaining work and it belongs
/// with the Vulkan world arm, which is the first thing that cannot use a GL
/// handle at all." This is that work, expressed as a second construction
/// path rather than a rewrite, so the GL path's calls are textually
/// unchanged.</para>
/// Campaign V slice V6i-2: both arrays are <see cref="IGpuTexture"/>s the
/// device created, and both slots were registered at build time, so
/// <see cref="TextureSlots"/> is a field read rather than a residency
/// negotiation. The GL construction path this used to sit alongside is
/// deleted as of Campaign V slice V11.
/// </summary>
private sealed class RhiArrays(
IGpuDevice device,
@ -149,30 +76,19 @@ public sealed unsafe class TerrainAtlas : IDisposable
public GpuTextureSlot AlphaSlot { get; set; } = GpuTextureSlot.Unassigned;
}
private readonly RhiArrays? _rhi;
private readonly RhiArrays _rhi;
/// <summary>
/// True when this atlas owns <see cref="IGpuTexture"/> arrays rather than raw
/// GL names. The two construction paths are mutually exclusive.
/// </summary>
internal bool IsBackendNeutral => _rhi is not null;
/// <summary>
/// The device-table slots for the terrain and alpha arrays on the
/// backend-neutral arm. Registered once at build time and re-registered only
/// by <see cref="SetAnisotropic"/>, which changes the sampler.
/// The device-table slots for the terrain and alpha arrays. Registered once
/// at build time and re-registered only by <see cref="SetAnisotropic"/>,
/// which changes the sampler.
/// </summary>
internal (GpuTextureSlot Terrain, GpuTextureSlot Alpha) TextureSlots =>
_rhi is null
? throw new InvalidOperationException(
"This TerrainAtlas owns GL names; ask it for GetTextureSlots(GlGpuDevice).")
: (_rhi.TerrainSlot, _rhi.AlphaSlot);
(_rhi.TerrainSlot, _rhi.AlphaSlot);
/// <summary>
/// Retail's terrain arrays are trilinear-filtered with the highest anisotropy
/// the quality preset allows; <see cref="SetAnisotropic"/> lowers it. The GL
/// path sets <c>GL_TEXTURE_MAX_ANISOTROPY</c> to 16 at build time, so the
/// backend-neutral path starts at the same value.
/// the quality preset allows; <see cref="SetAnisotropic"/> lowers it.
/// </summary>
private const float RetailMaxAnisotropy = 16f;
@ -192,11 +108,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
IReadOnlyList<uint> sideTCodes,
IReadOnlyList<uint> roadRCodes)
{
_gl = null;
_bindless = null;
_rhi = new RhiArrays(device, terrain, alpha, alphaSampler);
GlTexture = 0;
GlAlphaTexture = 0;
TerrainTypeToLayer = map;
LayerCount = layerCount;
TilingByLayer = tilingByLayer;
@ -211,72 +123,6 @@ public sealed unsafe class TerrainAtlas : IDisposable
ApplyAnisotropic(RetailMaxAnisotropy);
}
private TerrainAtlas(
GL gl,
Wb.BindlessSupport? bindless,
uint glTexture, IReadOnlyDictionary<uint, uint> map, int layerCount,
IReadOnlyList<float> tilingByLayer,
uint glAlphaTexture, int alphaLayerCount,
IReadOnlyList<byte> cornerLayers, IReadOnlyList<byte> sideLayers, IReadOnlyList<byte> roadLayers,
IReadOnlyList<uint> cornerTCodes, IReadOnlyList<uint> sideTCodes, IReadOnlyList<uint> roadRCodes)
{
_gl = gl;
_bindless = bindless;
GlTexture = glTexture;
TerrainTypeToLayer = map;
LayerCount = layerCount;
TilingByLayer = tilingByLayer;
GlAlphaTexture = glAlphaTexture;
AlphaLayerCount = alphaLayerCount;
CornerAlphaLayers = cornerLayers;
SideAlphaLayers = sideLayers;
RoadAlphaLayers = roadLayers;
CornerAlphaTCodes = cornerTCodes;
SideAlphaTCodes = sideTCodes;
RoadAlphaRCodes = roadRCodes;
if (bindless is not null)
{
_bindlessHandles = new BindlessTexturePair(
glTexture,
glAlphaTexture,
bindless.GetResidentHandle,
bindless.MakeNonResident);
_bindlessMutation = new BindlessTextureMutationGuard(_bindlessHandles);
}
}
/// <summary>
/// Build the atlas by walking Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc
/// for the mapping from TerrainTextureType to SurfaceTexture id, decoding each
/// to RGBA8, and uploading as layers in a single GL_TEXTURE_2D_ARRAY.
/// </summary>
public static TerrainAtlas Build(GL gl, IDatReaderWriter dats, Wb.BindlessSupport? bindless = null)
{
var textures = new GlTextureConstructionTransaction(new GlTextureNameApi(gl));
try
{
TerrainAtlas atlas = BuildCore(gl, dats, bindless, textures);
textures.Commit();
return atlas;
}
catch (Exception constructionFailure)
{
try
{
textures.Rollback();
}
catch (Exception cleanupFailure)
{
throw new GlResourceConstructionException(
"TerrainAtlas construction failed and its allocated OpenGL texture names did not cleanly roll back.",
textures,
[constructionFailure, cleanupFailure]);
}
throw;
}
}
/// <summary>
/// Campaign V slice V6i-2: the decode both construction paths share.
/// Splitting it out is what keeps the CPU logic single while the upload
@ -340,108 +186,6 @@ public sealed unsafe class TerrainAtlas : IDisposable
return new TerrainLayerDecode(decodedByType, tilingByType, maxW, maxH);
}
private static TerrainAtlas BuildCore(
GL gl,
IDatReaderWriter dats,
Wb.BindlessSupport? bindless,
GlTextureConstructionTransaction textures)
{
var region = dats.Get<Region>(0x13000000u)
?? throw new InvalidOperationException("Region dat id 0x13000000 missing");
var texMerge = region.TerrainInfo?.LandSurfaces?.TexMerge;
var terrainDesc = texMerge?.TerrainDesc;
if (terrainDesc is null || terrainDesc.Count == 0)
{
Console.WriteLine("WARN: TerrainDesc missing, using single white fallback layer");
return BuildFallback(gl, bindless, textures);
}
// ---- Terrain atlas (unchanged Phase 2b logic) ----
TerrainLayerDecode decode = DecodeTerrainLayers(dats, terrainDesc);
Dictionary<uint, DecodedTexture> decodedByType = decode.DecodedByType;
Dictionary<uint, uint> tilingByType = decode.TilingByType;
int maxW = decode.MaxWidth, maxH = decode.MaxHeight;
int layerCount = decodedByType.Count;
var map = new Dictionary<uint, uint>();
uint tex = TrackedTextureConstruction.Create(
textures,
gl,
"upload terrain atlas texture array",
texture =>
{
gl.BindTexture(TextureTarget.Texture2DArray, texture);
gl.TexImage3D(
TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8,
(uint)maxW, (uint)maxH, (uint)layerCount,
0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
int layerIdx = 0;
foreach (var kvp in decodedByType)
{
byte[] buffer = ResizeRgba8Nearest(kvp.Value, maxW, maxH);
fixed (byte* p = buffer)
{
gl.TexSubImage3D(
TextureTarget.Texture2DArray, 0,
0, 0, layerIdx,
(uint)maxW, (uint)maxH, 1,
GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
}
map[kvp.Key] = (uint)layerIdx;
layerIdx++;
}
// A.5 T19: generate mipmaps + trilinear + 16x anisotropic for distant-LB quality.
gl.GenerateMipmap(TextureTarget.Texture2DArray);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.LinearMipmapLinear);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
// GL_TEXTURE_MAX_ANISOTROPY = 0x84FE (GL_EXT_texture_filter_anisotropic / ARB_texture_filter_anisotropic).
gl.TexParameter(TextureTarget.Texture2DArray, (TextureParameterName)0x84FE, 16.0f);
gl.BindTexture(TextureTarget.Texture2DArray, 0);
});
var tilingByLayer = TerrainTextureTilingTable.Build(
map.Select(entry =>
(entry.Value, tilingByType.TryGetValue(entry.Key, out uint repeatCount)
? repeatCount
: 1u)));
Console.WriteLine($"TerrainAtlas: {layerCount} terrain layers at {maxW}x{maxH} (mipmaps+aniso16x)");
// ---- Alpha atlas (new in Phase 3c.2) ----
// texMerge is guaranteed non-null here: the early return above exited
// if texMerge?.TerrainDesc was null.
var alphaBuild = BuildAlphaAtlas(gl, dats, texMerge!, textures);
return new TerrainAtlas(
gl,
bindless,
tex, map, layerCount, tilingByLayer,
alphaBuild.gl, alphaBuild.layerCount,
alphaBuild.corner, alphaBuild.side, alphaBuild.road,
alphaBuild.cornerTCodes, alphaBuild.sideTCodes, alphaBuild.roadRCodes);
}
/// <summary>
/// Load corner, side, and road alpha maps from the TexMerge into a second
/// GL_TEXTURE_2D_ARRAY. AC ships these as 512×512 PFID_A8 textures;
/// <see cref="SurfaceDecoder.DecodeRenderSurface"/> expands each alpha byte
/// into all four RGBA channels so the shader can sample from any channel.
///
/// Layers are appended in TexMerge insertion order: corners first, then
/// sides, then roads. The returned index lists tell
/// <c>TerrainBlending.BuildSurface</c> which layer to cite for each
/// corner/side/road alpha source.
/// </summary>
private readonly record struct AlphaAtlasBuildResult(
uint gl, int layerCount,
IReadOnlyList<byte> corner, IReadOnlyList<byte> side, IReadOnlyList<byte> road,
IReadOnlyList<uint> cornerTCodes, IReadOnlyList<uint> sideTCodes, IReadOnlyList<uint> roadRCodes);
/// <summary>
/// Slice V6i-2: the alpha-map decode, shared by both construction paths for
/// the same reason <see cref="DecodeTerrainLayers"/> is.
@ -530,100 +274,12 @@ public sealed unsafe class TerrainAtlas : IDisposable
decodedMaxH);
}
private static AlphaAtlasBuildResult BuildAlphaAtlas(
GL gl,
IDatReaderWriter dats,
DatReaderWriter.Types.TexMerge texMerge,
GlTextureConstructionTransaction textures)
{
AlphaLayerDecode decode = DecodeAlphaLayers(dats, texMerge);
List<DecodedTexture> decoded = decode.Decoded;
List<byte> cornerLayers = decode.CornerLayers;
List<byte> sideLayers = decode.SideLayers;
List<byte> roadLayers = decode.RoadLayers;
List<uint> cornerTCodes = decode.CornerTCodes;
List<uint> sideTCodes = decode.SideTCodes;
List<uint> roadRCodes = decode.RoadRCodes;
if (decoded.Count == 0)
{
Console.WriteLine("WARN: no alpha maps loaded; alpha atlas will be a 1x1 white fallback");
uint fallbackAlpha = TrackedTextureConstruction.Create(
textures,
gl,
"upload fallback terrain alpha texture array",
texture =>
{
gl.BindTexture(TextureTarget.Texture2DArray, texture);
gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0,
GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
var white = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF };
fixed (byte* p = white)
gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1,
GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
gl.BindTexture(TextureTarget.Texture2DArray, 0);
});
return new AlphaAtlasBuildResult(
fallbackAlpha, 1,
cornerLayers, sideLayers, roadLayers,
cornerTCodes, sideTCodes, roadRCodes);
}
int aMaxW = decode.MaxWidth, aMaxH = decode.MaxHeight;
uint glAlpha = TrackedTextureConstruction.Create(
textures,
gl,
"upload terrain alpha texture array",
texture =>
{
gl.BindTexture(TextureTarget.Texture2DArray, texture);
gl.TexImage3D(
TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8,
(uint)aMaxW, (uint)aMaxH, (uint)decoded.Count,
0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
for (int i = 0; i < decoded.Count; i++)
{
var buffer = ResizeRgba8Nearest(decoded[i], aMaxW, aMaxH);
fixed (byte* p = buffer)
{
gl.TexSubImage3D(
TextureTarget.Texture2DArray, 0,
0, 0, i,
(uint)aMaxW, (uint)aMaxH, 1,
GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
}
}
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.ClampToEdge);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.ClampToEdge);
gl.BindTexture(TextureTarget.Texture2DArray, 0);
});
Console.WriteLine(
$"AlphaAtlas: {decoded.Count} layers at {aMaxW}x{aMaxH} "
+ $"(corners={cornerLayers.Count}, sides={sideLayers.Count}, roads={roadLayers.Count})");
return new AlphaAtlasBuildResult(
glAlpha, decoded.Count,
cornerLayers, sideLayers, roadLayers,
cornerTCodes, sideTCodes, roadRCodes);
}
/// <summary>
/// Campaign V slice V6i-2: build both arrays through
/// <see cref="IGpuDevice"/>.
///
/// <para>Same DAT reads, same decode, same layer ordering and the same
/// resize-to-max policy as <see cref="Build"/> — only the upload differs,
/// which is the whole point of splitting the decode out. The terrain array
/// Builds both arrays through <see cref="IGpuDevice"/>. The terrain array
/// gets a full mip chain (<see cref="IGpuTexture.GenerateMipChain"/> blits
/// it, because RGBA8 is a legal blit destination) and a repeat/anisotropic
/// sampler; the alpha array is single-level and clamped, exactly as the GL
/// texture parameters say.</para>
/// sampler; the alpha array is single-level and clamped, matching what the
/// deleted GL path's texture parameters said.
/// </summary>
internal static TerrainAtlas BuildBackendNeutral(IGpuDevice device, IDatReaderWriter dats)
{
@ -826,151 +482,27 @@ public sealed unsafe class TerrainAtlas : IDisposable
return dst;
}
private static TerrainAtlas BuildFallback(
GL gl,
Wb.BindlessSupport? bindless,
GlTextureConstructionTransaction textures)
{
var white = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF };
uint tex = TrackedTextureConstruction.Create(
textures,
gl,
"upload fallback terrain texture array",
texture =>
{
gl.BindTexture(TextureTarget.Texture2DArray, texture);
gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
fixed (byte* p = white)
gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1, GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
gl.BindTexture(TextureTarget.Texture2DArray, 0);
});
// Fallback alpha atlas: 1x1 white, no layers tracked
uint alphaTex = TrackedTextureConstruction.Create(
textures,
gl,
"upload fallback alpha texture array",
texture =>
{
gl.BindTexture(TextureTarget.Texture2DArray, texture);
gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
fixed (byte* p = white)
gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1, GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
gl.BindTexture(TextureTarget.Texture2DArray, 0);
});
return new TerrainAtlas(
gl,
bindless,
tex, new Dictionary<uint, uint> { [0] = 0u }, 1,
TerrainTextureTilingTable.Build(Array.Empty<(uint Layer, uint RepeatCount)>()),
alphaTex, 1,
Array.Empty<byte>(), Array.Empty<byte>(), Array.Empty<byte>(),
Array.Empty<uint>(), Array.Empty<uint>(), Array.Empty<uint>());
}
/// <summary>
/// A.5 T22.5: update GL_TEXTURE_MAX_ANISOTROPY on the terrain atlas at
/// runtime (called by
/// Update terrain-array anisotropy at runtime (called by
/// <see cref="AcDream.App.Settings.RuntimeSettingsController.ReapplyQualityPreset"/> when
/// the user changes Quality preset mid-session). Idempotent — calling with
/// the same level as the current setting is safe and produces no visual
/// change. The texture must not be resident-bindless when its parameters
/// are mutated; we temporarily make it non-resident if needed.
/// change.
/// </summary>
public void SetAnisotropic(int level)
{
if (_rhi is not null)
{
ApplyAnisotropic(level);
Console.WriteLine($"TerrainAtlas: anisotropic updated to {level}x");
return;
}
GL gl = RequireGl();
void Mutate()
{
_anisotropyBindingMutation.Execute(
() => unchecked((uint)GlResourceCommand.Execute(
gl,
"read terrain-array binding before anisotropy mutation",
() => gl.GetInteger(GetPName.TextureBinding2DArray))),
binding => GlResourceCommand.Execute(
gl,
"set terrain-array binding for anisotropy mutation",
() => gl.BindTexture(TextureTarget.Texture2DArray, binding)),
GlTexture,
() => GlResourceCommand.Execute(
gl,
"set terrain atlas anisotropy",
() =>
{
// GL_TEXTURE_MAX_ANISOTROPY = 0x84FE
gl.TexParameter(
TextureTarget.Texture2DArray,
(TextureParameterName)0x84FE,
(float)level);
}));
}
if (_bindlessMutation is not null)
_bindlessMutation.Execute(Mutate);
else
Mutate();
ApplyAnisotropic(level);
Console.WriteLine($"TerrainAtlas: anisotropic updated to {level}x");
}
/// <summary>
/// Slice V6i-2: the GL arm's context. The two construction paths are
/// mutually exclusive, so a null here means a backend-neutral atlas reached
/// GL-only code — a programming error, not a runtime condition.
/// </summary>
private GL RequireGl() =>
_gl ?? throw new InvalidOperationException(
"This TerrainAtlas owns IGpuTexture arrays and has no GL context.");
public void Dispose()
{
if (_rhi is not null)
{
// Slice V4t's teardown rule holds on both arms: the device dies with
// its callers, so the table entries are not released here —
// deferring through a possibly-disposed retirement queue would turn
// a clean shutdown into a throw. The images themselves route through
// the device's retirement queue, which is what IGpuTexture.Dispose
// does.
_rhi.Alpha.Dispose();
_rhi.Terrain.Dispose();
return;
}
_shutdown ??= new ResourceShutdownTransaction(
new ResourceShutdownStage(
"terrain atlas bindless residency",
[
new ResourceShutdownOperation(
"release terrain and alpha handles",
() => _bindlessHandles?.Release()),
]),
new ResourceShutdownStage(
"terrain atlas textures",
[
new ResourceShutdownOperation(
"delete terrain texture",
() => GlResourceCommand.DeleteTexture(
RequireGl(),
GlTexture,
$"delete terrain atlas texture {GlTexture}")),
new ResourceShutdownOperation(
"delete alpha texture",
() => GlResourceCommand.DeleteTexture(
RequireGl(),
GlAlphaTexture,
$"delete terrain alpha texture {GlAlphaTexture}")),
]));
_shutdown.CompleteOrThrow();
// Slice V4t's teardown rule: the device dies with its callers, so the
// table entries are not released here — deferring through a
// possibly-disposed retirement queue would turn a clean shutdown into a
// throw. The images themselves route through the device's retirement
// queue, which is what IGpuTexture.Dispose does.
_rhi.Alpha.Dispose();
_rhi.Terrain.Dispose();
}
}

View file

@ -292,7 +292,6 @@ public sealed unsafe partial class TerrainModernRenderer
throw;
}
_tilingBuffer = buffer;
_textureTilingUploaded = true;
}
encoder.BindUniformBuffer(

View file

@ -1,23 +1,23 @@
using System.Numerics;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Gl;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Terrain;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Phase N.5b modern terrain dispatcher. Single global VBO/EBO with a slot
/// allocator (one slot per landblock, 384 verts × 40 bytes = 15,360 bytes
/// per slot). Per-frame: build a DrawElementsIndirectCommand array from
/// visible slots, upload, dispatch via glMultiDrawElementsIndirect. Atlas
/// textures bound via bindless handles set per-frame as sampler uniforms.
/// Phase N.5b modern terrain dispatcher. Single global vertex/index arena with
/// a slot allocator (one slot per landblock, 384 verts × 40 bytes = 15,360
/// bytes per slot). Per-frame: build a DrawElementsIndirectCommand array from
/// visible slots and dispatch via one multi-draw-indirect call. Atlas
/// textures bound via the device's global texture table.
///
/// Total ~6-8 GL calls per frame for terrain regardless of visible
/// landblock count.
/// <para>Campaign V slice V11 deleted the raw-GL submission arm
/// (<c>TerrainModernRenderer.cs</c>'s former GL fields/constructor/Draw/Dispose
/// bodies); the RHI arm this file now exclusively hosts the shared allocator
/// and visibility logic for is defined in <c>TerrainModernRenderer.Rhi.cs</c>.</para>
/// </summary>
public sealed unsafe partial class TerrainModernRenderer : IDisposable
public sealed partial class TerrainModernRenderer : IDisposable
{
// VertsPerLandblock MUST stay divisible by 6 — terrain_modern.vert uses
// `gl_VertexID % 6` to pick the cell-corner index (BL/BR/TR/TL), and
@ -32,12 +32,6 @@ public sealed unsafe partial class TerrainModernRenderer : IDisposable
private const int IndexSize = sizeof(uint);
private const float LandblockSize = LandblockMesh.LandblockSize; // 192
// Campaign V slice V6j: null on the RHI arm, where every statement below that
// reaches one of these is forked into TerrainModernRenderer.Rhi.cs. The GL arm
// executes exactly what it did before.
private readonly GL? _gl;
private readonly BindlessSupport? _bindless;
private readonly Shader? _shader;
private readonly TerrainAtlas _atlas;
/// <summary>A.5 T22.5: exposes the terrain atlas so callers can update
@ -46,7 +40,6 @@ public sealed unsafe partial class TerrainModernRenderer : IDisposable
private readonly GpuRetiredTerrainSlotAllocator _alloc;
private readonly GpuRetirementLedger _retirementLedger;
private RetryableResourceReleaseLedger? _disposeResources;
private bool _disposed;
// Per-slot live data (index by slot integer; null entries are unused slots).
@ -55,58 +48,22 @@ public sealed unsafe partial class TerrainModernRenderer : IDisposable
// Reverse map: landblockId -> slot, for RemoveLandblock and replacement.
private readonly Dictionary<uint, int> _idToSlot = new();
// GPU buffers.
private uint _globalVao;
private uint _globalVbo;
private uint _globalEbo;
// Backing-store capacity bookkeeping, shared with the RHI arm's arena.
private long _globalVboCapacityBytes;
private long _globalEboCapacityBytes;
private uint _indirectBuffer;
private int _indirectCapacity;
private sealed class DynamicIndirectBuffer
{
public uint Buffer;
public int Capacity;
}
private readonly List<DynamicIndirectBuffer>[] _indirectBuffersByFrame =
[[], [], []];
// Per-GPU-fenced-frame-slot draw bookkeeping, shared with the RHI arm.
private int _dynamicFrameSlot;
private int _dynamicBufferCursor;
private bool _dynamicFrameStarted;
internal int DynamicIndirectBufferCount =>
_indirectBuffersByFrame.Sum(frameBuffers => frameBuffers.Count);
// Phase U.3: terrain clip UBO (binding=2, terrain_modern.vert TerrainClip).
// The shared one is created + uploaded by the GameWindow-level ClipFrame and
// handed in via SetClipUbo. When 0, we bind a lazily-created no-clip fallback
// (count 0 = ungated) so the shader never reads an unbound UBO at binding=2.
private TerrainClipBufferBinding _sharedClipBinding;
private uint _fallbackClipUbo;
// Campaign V slice V2b (2026-07-27): uTerrainHandle/uAlphaHandle (uvec2)
// became uTextureIndexA/uTextureIndexB (uint table slots) — cached
// uniform locations (matrix uniforms are set by name via Shader.SetMatrix4).
private int _uTextureIndexALoc;
private int _uTextureIndexBLoc;
private bool _textureTilingUploaded;
// Campaign V slice V6f-2: the 36 per-layer tiling factors used to be a loose
// `uniform float uTexTiling[36]`, which Vulkan GLSL cannot declare at all.
// They now live in the uniform buffer GpuBindingModel reserved
// UniformTerrainTiling (binding 3) for — see terrain_modern.frag for the
// std140 packing and why it is vec4[9] rather than float[36].
private uint _tilingUbo;
// Campaign V slice V4t: the interim per-renderer GlBindlessHandleTable is
// retired. TerrainAtlas hands out GpuTextureSlots from the device's one
// table and this renderer flushes and binds that table at
// GpuBindingModel.StorageTextureTable itself, because it still submits
// through raw GL and so never reaches GlGpuDevice.FlushBeforeDraw. Null on
// a backend with no GL device, where this renderer is never constructed.
private readonly GlGpuDevice? _gpuDevice;
/// <summary>
/// The dynamic per-frame-slot indirect-command buffer pool this used to
/// report was raw-GL-only bookkeeping, deleted with that arm at Campaign V
/// slice V11. The RHI arm allocates its indirect-command storage from the
/// GPU frame's own upload ring instead, so there is no separate pool to
/// count.
/// </summary>
internal int DynamicIndirectBufferCount => 0;
// Reusable per-frame buffers.
private readonly List<int> _visibleSlots = new();
@ -126,183 +83,18 @@ public sealed unsafe partial class TerrainModernRenderer : IDisposable
public void BeginVisibilityFrame() => _visibleCellIds.Clear();
internal TerrainModernRenderer(
GL gl,
BindlessSupport bindless,
Shader shader,
TerrainAtlas atlas,
GlGpuDevice? gpuDevice,
int initialSlotCapacity = 64)
: this(
gl,
bindless,
shader,
atlas,
gpuDevice,
ImmediateGpuResourceRetirementQueue.Instance,
initialSlotCapacity)
{
}
internal TerrainModernRenderer(
GL gl,
BindlessSupport bindless,
Shader shader,
TerrainAtlas atlas,
GlGpuDevice? gpuDevice,
IGpuResourceRetirementQueue resourceRetirement,
int initialSlotCapacity = 64)
{
_gl = gl;
_bindless = bindless;
_shader = shader;
_atlas = atlas;
_gpuDevice = gpuDevice;
ArgumentNullException.ThrowIfNull(resourceRetirement);
_retirementLedger = new GpuRetirementLedger(resourceRetirement);
_alloc = new GpuRetiredTerrainSlotAllocator(initialSlotCapacity, resourceRetirement);
_slots = new SlotData?[initialSlotCapacity];
_uTextureIndexALoc = _gl.GetUniformLocation(_shader.Program, "uTextureIndexA");
_uTextureIndexBLoc = _gl.GetUniformLocation(_shader.Program, "uTextureIndexB");
var constructionResources = new ResourceCleanupGroup();
try
{
// Campaign V slice V6f-2: the tiling UBO. Fixed size — the table is
// 36 immutable floats packed four to a vec4 — so it is allocated
// once here and written once on the first bound draw, which is the
// same cadence the glUniform1fv it replaces already had.
_tilingUbo = TrackedGlResource.CreateBuffer(
_gl,
"creating terrain tiling UBO");
RetryableGpuResourceRelease tilingUboRelease =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
_tilingUbo,
TerrainTextureTilingTable.UniformBufferBytes,
"rolling back terrain tiling UBO");
constructionResources.Add("terrain tiling UBO", tilingUboRelease.Run);
TrackedGlResource.AllocateBufferStorage(
_gl,
BufferTargetARB.UniformBuffer,
_tilingUbo,
0,
TerrainTextureTilingTable.UniformBufferBytes,
BufferUsageARB.StaticDraw,
"allocating terrain tiling UBO");
_globalVao = TrackedGlResource.CreateVertexArray(
_gl,
"creating terrain global VAO");
RetryableGpuResourceRelease globalVaoRelease =
TrackedGlResource.CreateRetryableVertexArrayDeletion(
_gl,
_globalVao,
"rolling back terrain global VAO");
constructionResources.Add(
"terrain global VAO",
globalVaoRelease.Run);
_globalVbo = TrackedGlResource.CreateBuffer(
_gl,
"creating terrain global vertex buffer");
RetryableGpuResourceRelease globalVboRelease =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
_globalVbo,
() => _globalVboCapacityBytes,
"rolling back terrain global vertex buffer");
constructionResources.Add(
"terrain global vertex buffer",
globalVboRelease.Run);
_globalEbo = TrackedGlResource.CreateBuffer(
_gl,
"creating terrain global index buffer");
RetryableGpuResourceRelease globalEboRelease =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
_globalEbo,
() => _globalEboCapacityBytes,
"rolling back terrain global index buffer");
constructionResources.Add(
"terrain global index buffer",
globalEboRelease.Run);
AllocateGpuBuffers(initialSlotCapacity);
GlResourceCommand.Execute(
_gl,
"configure terrain global vertex array",
() => ConfigureVao(_globalVao, _globalVbo, _globalEbo));
constructionResources.TransferAll();
}
catch (Exception constructionFailure)
{
constructionResources.RollbackConstructionAndThrow(
"TerrainModernRenderer construction failed and its GL prefix did not cleanly roll back.",
constructionFailure);
}
}
/// <summary>
/// Resets the indirect-command submission cursor for a GPU-fenced frame
/// slot. A retail outside view may draw terrain more than once in a frame;
/// each draw receives storage that cannot overwrite an earlier command.
/// Resets the per-GPU-fenced-frame-slot draw state. A retail outside view
/// may draw terrain more than once in a frame.
/// </summary>
public void BeginFrame(int frameSlot)
{
if ((uint)frameSlot >= (uint)_indirectBuffersByFrame.Length)
throw new ArgumentOutOfRangeException(nameof(frameSlot));
ArgumentOutOfRangeException.ThrowIfNegative(frameSlot);
_retirementLedger.RetryPendingPublications();
_dynamicFrameSlot = frameSlot;
_dynamicBufferCursor = 0;
_dynamicFrameStarted = true;
}
private void ActivateNextIndirectBuffer()
{
if (!_dynamicFrameStarted)
throw new InvalidOperationException("BeginFrame must be called before drawing terrain.");
List<DynamicIndirectBuffer> frameBuffers = _indirectBuffersByFrame[_dynamicFrameSlot];
if (_dynamicBufferCursor == frameBuffers.Count)
{
uint buffer = TrackedGlResource.CreateBuffer(
_gl!,
$"creating terrain indirect buffer for frame slot {_dynamicFrameSlot}");
try
{
frameBuffers.Add(new DynamicIndirectBuffer { Buffer = buffer });
}
catch
{
TrackedGlResource.DeleteBuffer(
_gl!,
buffer,
0,
"rolling back terrain indirect buffer");
throw;
}
}
DynamicIndirectBuffer active = frameBuffers[_dynamicBufferCursor++];
_indirectBuffer = active.Buffer;
_indirectCapacity = active.Capacity;
}
private void PersistIndirectCapacity()
{
_indirectBuffersByFrame[_dynamicFrameSlot][_dynamicBufferCursor - 1].Capacity =
_indirectCapacity;
}
/// <summary>
/// Hand the renderer the current aligned terrain-clip range (binding=2).
/// Each outside-view slice occupies a distinct range in the current
/// GPU-fenced frame's UBO arena.
/// </summary>
public void SetClipUbo(TerrainClipBufferBinding sharedClipBinding) =>
_sharedClipBinding = sharedClipBinding;
/// <summary>
/// Two-tier streaming entry point. Accepts a prebuilt mesh from
/// <see cref="LandblockStreamResult.Loaded.MeshData"/> built on the worker
@ -343,59 +135,26 @@ public sealed unsafe partial class TerrainModernRenderer : IDisposable
EnsureCapacity(newCap);
}
// Bake worldOrigin into vertex positions; capture min/max Z for AABB.
var bakedVerts = new TerrainVertex[VertsPerLandblock];
float zMin = float.MaxValue, zMax = float.MinValue;
for (int i = 0; i < VertsPerLandblock; i++)
{
var v = meshData.Vertices[i];
var worldPos = v.Position + worldOrigin;
bakedVerts[i] = new TerrainVertex(worldPos, v.Normal, v.Data0, v.Data1, v.Data2, v.Data3);
if (worldPos.Z < zMin) zMin = worldPos.Z;
if (worldPos.Z > zMax) zMax = worldPos.Z;
}
if (zMin == float.MaxValue) { zMin = 0f; zMax = 0f; }
// Bake baseVertex into indices on the CPU side (driver-portable pattern).
uint baseVertex = (uint)(slot * VertsPerLandblock);
var bakedIndices = new uint[IndicesPerLandblock];
for (int i = 0; i < IndicesPerLandblock; i++)
bakedIndices[i] = meshData.Indices[i] + baseVertex;
// glBufferSubData into the slot's VBO + EBO regions.
nint vboByteOffset = (nint)(slot * VertsPerLandblock * VertexSize);
nint eboByteOffset = (nint)(slot * IndicesPerLandblock * IndexSize);
if (_gl is null)
// Bake worldOrigin into vertex positions; capture min/max Z for AABB.
var bakedVerts = new TerrainVertex[VertsPerLandblock];
float zMin = float.MaxValue, zMax = float.MinValue;
for (int i = 0; i < VertsPerLandblock; i++)
{
UploadRhiLandblock(slot, bakedVerts, bakedIndices);
}
else
{
fixed (TerrainVertex* p = bakedVerts)
{
TrackedGlResource.UpdateBufferSubData(
_gl,
BufferTargetARB.ArrayBuffer,
_globalVbo,
vboByteOffset,
VertsPerLandblock * VertexSize,
p,
$"uploading terrain vertices for 0x{landblockId:X8}");
var v = meshData.Vertices[i];
var worldPos = v.Position + worldOrigin;
bakedVerts[i] = new TerrainVertex(worldPos, v.Normal, v.Data0, v.Data1, v.Data2, v.Data3);
if (worldPos.Z < zMin) zMin = worldPos.Z;
if (worldPos.Z > zMax) zMax = worldPos.Z;
}
if (zMin == float.MaxValue) { zMin = 0f; zMax = 0f; }
fixed (uint* p = bakedIndices)
{
TrackedGlResource.UpdateBufferSubData(
_gl,
BufferTargetARB.ElementArrayBuffer,
_globalEbo,
eboByteOffset,
IndicesPerLandblock * IndexSize,
p,
$"uploading terrain indices for 0x{landblockId:X8}");
}
}
// Bake baseVertex into indices on the CPU side (driver-portable pattern).
uint baseVertex = (uint)(slot * VertsPerLandblock);
var bakedIndices = new uint[IndicesPerLandblock];
for (int i = 0; i < IndicesPerLandblock; i++)
bakedIndices[i] = meshData.Indices[i] + baseVertex;
UploadRhiLandblock(slot, bakedVerts, bakedIndices);
_slots[slot] = new SlotData
{
@ -472,128 +231,15 @@ public sealed unsafe partial class TerrainModernRenderer : IDisposable
}
if (_visibleSlots.Count == 0) return;
// Campaign V slice V6j: the command array is built the same way on both
// arms; only where it lands differs. The RHI arm writes it into a frame
// ring slice, which retires the per-frame-slot indirect buffer pool
// structurally — every allocation within a frame is already distinct
// memory that outlives the draw recorded against it.
BuildIndirectCommands();
if (_gl is null)
{
if (!_dynamicFrameStarted)
throw new InvalidOperationException("BeginFrame must be called before drawing terrain.");
DrawRhi(viewProjection, _visibleSlots.Count);
return;
}
ActivateNextIndirectBuffer();
// Grow indirect buffer if needed.
if (_visibleSlots.Count > _indirectCapacity)
{
int grownCapacity = Math.Max(64, _visibleSlots.Count * 2);
TrackedGlResource.AllocateBufferStorage(
_gl,
GLEnum.DrawIndirectBuffer,
_indirectBuffer,
checked((long)_indirectCapacity * sizeof(DrawElementsIndirectCommand)),
checked((long)grownCapacity * sizeof(DrawElementsIndirectCommand)),
GLEnum.DynamicDraw,
"growing terrain indirect command buffer");
_indirectCapacity = grownCapacity;
}
// Upload DEIC array.
fixed (DrawElementsIndirectCommand* p = _deicScratch)
{
TrackedGlResource.UpdateBufferSubData(
_gl,
GLEnum.DrawIndirectBuffer,
_indirectBuffer,
0,
checked((long)_visibleSlots.Count * sizeof(DrawElementsIndirectCommand)),
p,
"uploading terrain indirect commands");
}
PersistIndirectCapacity();
// Bind shader + uniforms + atlas handles.
// Verified Phase W Stage 4 (T4.2): terrain projects from the camera view-proj;
// no separate landscape viewpoint to sync. uViewProjection derives from
// the ICamera passed into this method — the same camera used for all other
// renderers in the unified pipeline. Retail's LScape::update_viewpoint
// pre-positions terrain to the outdoor landcell, but acdream uses the
// unified camera matrix everywhere, so no separate viewpoint divergence can occur.
_shader!.Use();
UploadTextureTilingOnce();
// Campaign V slice V6f-2: bind the tiling UBO every draw, not once. GL's
// uniform-buffer binding points are global and shared with the sky's
// params block and the SceneLighting block, so a renderer that runs
// between two terrain draws can take binding 3 out from under us.
// Self-contained state, per feedback_render_self_contained_gl_state.
_gl.BindBufferBase(
BufferTargetARB.UniformBuffer,
Gpu.GpuBindingModel.UniformTerrainTiling,
_tilingUbo);
// Campaign V slice V6f-1: one uViewProjection, matching the field
// GpuPushConstants already carries, instead of the separate uView and
// uProjection the shader used to combine per vertex. viewProjection is
// the same product the visibility pass above already computed.
_shader.SetMatrix4("uViewProjection", viewProjection);
// Campaign V slice V2b: pass each texture's binding=9 table slot
// instead of the raw uvec2 handle. GLSL reconstructs
// sampler2DArray(ACDREAM_TEXTURE_HANDLE(uTextureIndexA)) at the use
// site — see terrain_modern.frag. Slice V4t: the slots come from the
// device's one table rather than a table private to this renderer.
(GpuTextureSlot terrainSlot, GpuTextureSlot alphaSlot) =
_atlas.GetTextureSlots(GpuDevice);
FlushAndBindTextureTable();
_gl.ProgramUniform1(_shader.Program, _uTextureIndexALoc, terrainSlot.Index);
_gl.ProgramUniform1(_shader.Program, _uTextureIndexBLoc, alphaSlot.Index);
// Phase U.3: bind the terrain clip UBO (binding=2). Shared ClipFrame UBO
// when wired, else the no-clip fallback (count 0 = ungated terrain).
BindClipUboBinding2();
// #108-residual: retail terrain is SINGLE-SIDED — ACRender::landPolysDraw
// (0x006b7040) draws each land triangle ONLY when the camera is on the
// POSITIVE (upper) side of its plane (Plane::which_side2 vs
// Render::FrameCurrent, zFightTerrainAdjust bias). GL backface culling
// evaluates the same per-triangle eye-side predicate at rasterization.
// LandblockMesh emits every triangle CCW in world XY seen from above
// (LandblockMeshTests winding pin), which the unified camera chain
// (CreateLookAt up=+Z + Numerics perspective) maps to CCW window
// winding from above / CW from below (TerrainCullOrientationTests) —
// so FrontFace(Ccw)+Cull(Back) keeps the top side and culls the
// underside. WB drew the whole world with culling DISABLED
// frame-globally (WB GameScene.cs:841 — an editor camera goes
// underground); inheriting that drew terrain DOUBLE-SIDED, and a
// below-grade eye (cellar ascent) saw the UNDERSIDE of the grade
// sheet through the exit-door aperture — the #108 grass window.
// Self-contained state per feedback_render_self_contained_gl_state;
// the frame-global CW + cull-off baseline is restored after the draw.
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.Ccw);
_gl.BindVertexArray(_globalVao);
_gl.MemoryBarrier(MemoryBarrierMask.CommandBarrierBit);
_gl.MultiDrawElementsIndirect(
PrimitiveType.Triangles, DrawElementsType.UnsignedInt,
(void*)0,
(uint)_visibleSlots.Count,
(uint)sizeof(DrawElementsIndirectCommand));
_gl.BindVertexArray(0);
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0);
_gl.FrontFace(FrontFaceDirection.CW);
_gl.Disable(EnableCap.CullFace);
if (!_dynamicFrameStarted)
throw new InvalidOperationException("BeginFrame must be called before drawing terrain.");
DrawRhi(viewProjection, _visibleSlots.Count);
}
/// <summary>
/// Builds this frame's <c>DrawElementsIndirectCommand</c> array from the
/// visible slot list. Pure CPU, identical on both arms.
/// visible slot list. Pure CPU.
/// </summary>
private void BuildIndirectCommands()
{
@ -618,298 +264,13 @@ public sealed unsafe partial class TerrainModernRenderer : IDisposable
if (_disposed)
return;
_retirementLedger.RetryPendingPublications();
if (_gl is null)
{
DisposeRhi();
return;
}
if (_disposeResources is null)
{
var releases = new List<(string Name, Action Release)>();
if (_globalVao != 0)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableVertexArrayDeletion(
_gl,
_globalVao,
"deleting terrain global VAO");
releases.Add(("global-vao", release.Run));
}
if (_globalVbo != 0)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
_globalVbo,
_globalVboCapacityBytes,
"deleting terrain global vertex buffer");
releases.Add(("global-vbo", release.Run));
}
if (_globalEbo != 0)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
_globalEbo,
_globalEboCapacityBytes,
"deleting terrain global index buffer");
releases.Add(("global-ebo", release.Run));
}
for (int frame = 0; frame < _indirectBuffersByFrame.Length; frame++)
{
List<DynamicIndirectBuffer> frameBuffers = _indirectBuffersByFrame[frame];
for (int index = 0; index < frameBuffers.Count; index++)
{
DynamicIndirectBuffer buffer = frameBuffers[index];
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
buffer.Buffer,
checked((long)buffer.Capacity * sizeof(DrawElementsIndirectCommand)),
"deleting terrain indirect command buffer");
releases.Add(($"indirect-{frame}-{index}", release.Run));
}
}
if (_fallbackClipUbo != 0)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
_fallbackClipUbo,
ClipFrame.TerrainUboBytes,
"deleting terrain fallback clip UBO");
releases.Add(("fallback-clip-ubo", release.Run));
}
if (_tilingUbo != 0)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
_tilingUbo,
TerrainTextureTilingTable.UniformBufferBytes,
"deleting terrain tiling UBO");
releases.Add(("tiling-ubo", release.Run));
}
_disposeResources = new RetryableResourceReleaseLedger(releases);
}
ResourceReleaseAttempt attempt = _disposeResources.Advance();
if (!_disposeResources.IsComplete)
{
throw attempt.ToException(
"One or more terrain GPU resources could not be released.");
}
_globalVao = 0;
_globalVbo = 0;
_globalEbo = 0;
_globalVboCapacityBytes = 0;
_globalEboCapacityBytes = 0;
foreach (List<DynamicIndirectBuffer> frameBuffers in _indirectBuffersByFrame)
frameBuffers.Clear();
_indirectBuffer = 0;
_indirectCapacity = 0;
_dynamicFrameStarted = false;
_fallbackClipUbo = 0;
_disposeResources = null;
_disposed = true;
if (attempt.HasFailures)
{
throw attempt.ToException(
"Terrain GPU resources released with exceptional committed outcomes.");
}
DisposeRhi();
}
// ----------------------------------------------------------------
// Private helpers
// ----------------------------------------------------------------
/// <summary>
/// Upload the texture-array adapter for retail's per-surface repeat count.
/// Retail passes <c>TerrainTex::tex_tiling</c> directly to
/// <c>ImgTex::TileCSI</c> / <c>ImgTex::MergeTexture</c>
/// (`TexMerge::CopyAndTile` 0x00503580, `TexMerge::Merge` 0x005038C0).
/// Uniform values persist for the lifetime of this linked shader program,
/// so the immutable atlas table is uploaded on its first bound draw.
/// </summary>
private void UploadTextureTilingOnce()
{
if (_textureTilingUploaded)
return;
if (_atlas.TilingByLayer.Count != TerrainTextureTilingTable.LayerCapacity)
{
throw new InvalidOperationException(
$"Terrain tiling table has {_atlas.TilingByLayer.Count} entries; " +
$"expected {TerrainTextureTilingTable.LayerCapacity}.");
}
// Campaign V slice V6f-2: one whole-buffer write into the binding=3
// uniform buffer, replacing the glUniform1fv into the loose array. The
// block is std140, so each value sits at a 16-byte stride with three
// dead words after it; the span is cleared first so those words are
// zero rather than whatever the stack held.
Span<byte> block = stackalloc byte[TerrainTextureTilingTable.UniformBufferBytes];
block.Clear();
for (int i = 0; i < TerrainTextureTilingTable.LayerCapacity; i++)
{
BitConverter.TryWriteBytes(
block[(i * TerrainTextureTilingTable.UniformElementStrideBytes)..],
_atlas.TilingByLayer[i]);
}
fixed (byte* p = block)
{
TrackedGlResource.UpdateBufferSubData(
_gl!,
BufferTargetARB.UniformBuffer,
_tilingUbo,
0,
TerrainTextureTilingTable.UniformBufferBytes,
p,
"uploading terrain tiling UBO");
}
_textureTilingUploaded = true;
}
/// <summary>
/// The GL device whose texture table this renderer samples through.
/// Campaign V slice V4t: a terrain renderer without one could not resolve a
/// single texture, so the failure names the composition that built it rather
/// than dereferencing null mid-draw.
/// </summary>
private GlGpuDevice GpuDevice => _gpuDevice ?? throw new InvalidOperationException(
"TerrainModernRenderer was constructed without a GL GPU device: its texture " +
"slots come from that device's table (Campaign V slice V4t).");
/// <summary>
/// Campaign V slice V4t: drains the device texture table's dirty runs and
/// (re)binds it at <see cref="GpuBindingModel.StorageTextureTable"/>.
/// Terrain registers at most two slots (the terrain and alpha atlases), so
/// the table is dirty only on the atlas's first draw — but the bind is
/// unconditional, because GL storage-buffer binding points are global and
/// another renderer's binding 9 sits there between two terrain draws.
/// Deleted with the raw-GL world path when the Vulkan world arm lands and
/// this renderer's draws go through the encoder, which binds the same table
/// on every pipeline bind.
/// </summary>
private void FlushAndBindTextureTable()
{
GlGpuDevice device = GpuDevice;
device.FlushTextureTable();
_gl!.BindBufferBase(
GLEnum.ShaderStorageBuffer,
GpuBindingModel.StorageTextureTable,
device.TextureTableGlName);
}
/// <summary>
/// Phase U.3: bind the terrain clip UBO to binding=2. Prefers the shared
/// <see cref="ClipFrame"/> UBO range (<see cref="SetClipUbo"/>); otherwise lazily
/// creates + binds a no-clip fallback (count 0 = ungated) so the shader never
/// reads an unbound UBO. The fallback is std140-sized to
/// <see cref="ClipFrame.TerrainUboBytes"/> and zero-filled (count 0).
/// </summary>
private void BindClipUboBinding2()
{
if (_sharedClipBinding.IsValid)
{
_sharedClipBinding.Bind(_gl!);
return;
}
if (_fallbackClipUbo == 0)
{
var zero = stackalloc byte[ClipFrame.TerrainUboBytes];
for (int i = 0; i < ClipFrame.TerrainUboBytes; i++) zero[i] = 0;
uint fallback = TrackedGlResource.CreateBuffer(
_gl!,
"creating terrain fallback clip UBO");
try
{
TrackedGlResource.AllocateBufferStorage(
_gl!,
BufferTargetARB.UniformBuffer,
fallback,
0,
ClipFrame.TerrainUboBytes,
BufferUsageARB.DynamicDraw,
zero,
"allocating terrain fallback clip UBO");
_fallbackClipUbo = fallback;
}
catch
{
TrackedGlResource.DeleteBuffer(
_gl!,
fallback,
0,
"rolling back terrain fallback clip UBO");
throw;
}
}
_gl!.BindBufferBase(BufferTargetARB.UniformBuffer,
ClipFrame.TerrainClipUboBinding, _fallbackClipUbo);
}
private void AllocateGpuBuffers(int capacitySlots)
{
long vboBytes = checked((long)capacitySlots * VertsPerLandblock * VertexSize);
long eboBytes = checked((long)capacitySlots * IndicesPerLandblock * IndexSize);
TrackedGlResource.AllocateBufferStorage(
_gl!,
BufferTargetARB.ArrayBuffer,
_globalVbo,
_globalVboCapacityBytes,
vboBytes,
BufferUsageARB.DynamicDraw,
"allocating terrain global vertex storage");
_globalVboCapacityBytes = vboBytes;
TrackedGlResource.AllocateBufferStorage(
_gl!,
BufferTargetARB.ElementArrayBuffer,
_globalEbo,
_globalEboCapacityBytes,
eboBytes,
BufferUsageARB.DynamicDraw,
"allocating terrain global index storage");
_globalEboCapacityBytes = eboBytes;
}
private void ConfigureVao(uint vao, uint vbo, uint ebo)
{
_gl!.BindVertexArray(vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, vbo);
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, ebo);
uint stride = (uint)VertexSize;
// location 0: Position
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
// location 1: Normal
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
// locations 2-5: Data0..Data3 (uvec4 byte attributes)
nint dataOffset = 6 * sizeof(float);
_gl.EnableVertexAttribArray(2);
_gl.VertexAttribIPointer(2, 4, VertexAttribIType.UnsignedByte, stride, (void*)dataOffset);
_gl.EnableVertexAttribArray(3);
_gl.VertexAttribIPointer(3, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 4));
_gl.EnableVertexAttribArray(4);
_gl.VertexAttribIPointer(4, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 8));
_gl.EnableVertexAttribArray(5);
_gl.VertexAttribIPointer(5, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 12));
_gl.BindVertexArray(0);
GLHelpers.ThrowOnResourceError(_gl, "configuring terrain VAO");
}
internal static void CollectVisibleCells(
HashSet<uint> destination,
uint landblockId,
@ -1038,132 +399,7 @@ public sealed unsafe partial class TerrainModernRenderer : IDisposable
{
if (newCapacity <= _alloc.Capacity)
return;
if (_gl is null)
{
EnsureRhiCapacity(newCapacity);
return;
}
var grownSlots = new SlotData?[newCapacity];
Array.Copy(_slots, grownSlots, _slots.Length);
long newVboBytes = checked((long)newCapacity * VertsPerLandblock * VertexSize);
long newEboBytes = checked((long)newCapacity * IndicesPerLandblock * IndexSize);
uint newVbo = 0;
uint newEbo = 0;
uint newVao = 0;
long allocatedNewVboBytes = 0;
long allocatedNewEboBytes = 0;
bool published = false;
try
{
newVbo = TrackedGlResource.CreateBuffer(
_gl,
"creating grown terrain vertex buffer");
TrackedGlResource.AllocateBufferStorage(
_gl,
BufferTargetARB.ArrayBuffer,
newVbo,
0,
newVboBytes,
BufferUsageARB.DynamicDraw,
"allocating grown terrain vertex buffer");
allocatedNewVboBytes = newVboBytes;
newEbo = TrackedGlResource.CreateBuffer(
_gl,
"creating grown terrain index buffer");
TrackedGlResource.AllocateBufferStorage(
_gl,
BufferTargetARB.ElementArrayBuffer,
newEbo,
0,
newEboBytes,
BufferUsageARB.DynamicDraw,
"allocating grown terrain index buffer");
allocatedNewEboBytes = newEboBytes;
GLHelpers.ThrowOnResourceError(_gl, "copying terrain buffers (precondition)");
_gl.BindBuffer(BufferTargetARB.CopyReadBuffer, _globalVbo);
_gl.BindBuffer(BufferTargetARB.CopyWriteBuffer, newVbo);
_gl.CopyBufferSubData(
CopyBufferSubDataTarget.CopyReadBuffer,
CopyBufferSubDataTarget.CopyWriteBuffer,
0,
0,
checked((nuint)_globalVboCapacityBytes));
_gl.BindBuffer(BufferTargetARB.CopyReadBuffer, _globalEbo);
_gl.BindBuffer(BufferTargetARB.CopyWriteBuffer, newEbo);
_gl.CopyBufferSubData(
CopyBufferSubDataTarget.CopyReadBuffer,
CopyBufferSubDataTarget.CopyWriteBuffer,
0,
0,
checked((nuint)_globalEboCapacityBytes));
GLHelpers.ThrowOnResourceError(_gl, "copying terrain buffers");
newVao = TrackedGlResource.CreateVertexArray(
_gl,
"creating grown terrain VAO");
ConfigureVao(newVao, newVbo, newEbo);
uint oldVao = _globalVao;
uint oldVbo = _globalVbo;
uint oldEbo = _globalEbo;
long oldVboBytes = _globalVboCapacityBytes;
long oldEboBytes = _globalEboCapacityBytes;
_globalVao = newVao;
_globalVbo = newVbo;
_globalEbo = newEbo;
_globalVboCapacityBytes = newVboBytes;
_globalEboCapacityBytes = newEboBytes;
_slots = grownSlots;
_alloc.GrowTo(newCapacity);
published = true;
// Older submitted draws captured the former VAO/buffer bindings.
// Retire the complete old set only after the replacement is valid.
RetryableGpuResourceRelease oldVaoRelease =
TrackedGlResource.CreateRetryableVertexArrayDeletion(
_gl,
oldVao,
"retiring terrain VAO after growth");
RetryableGpuResourceRelease oldVboRelease =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
oldVbo,
oldVboBytes,
"retiring terrain vertex buffer after growth");
RetryableGpuResourceRelease oldEboRelease =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
oldEbo,
oldEboBytes,
"retiring terrain index buffer after growth");
_retirementLedger.RetireMany(
[oldVaoRelease, oldVboRelease, oldEboRelease]);
}
finally
{
if (!published)
{
TrackedGlResource.DeleteVertexArray(
_gl,
newVao,
"rolling back grown terrain VAO");
TrackedGlResource.DeleteBuffer(
_gl,
newVbo,
allocatedNewVboBytes,
"rolling back grown terrain vertex buffer");
TrackedGlResource.DeleteBuffer(
_gl,
newEbo,
allocatedNewEboBytes,
"rolling back grown terrain index buffer");
}
}
EnsureRhiCapacity(newCapacity);
}
private sealed class SlotData

View file

@ -455,7 +455,8 @@ public sealed class TextRenderer : IDisposable
/// into it, and issues one non-indexed draw. Replaces the old growable
/// per-flight VBO + <c>BufferSubData</c> pattern: every UI vertex upload is
/// now the frame's shared ring, reset once per frame by
/// <see cref="AcDream.App.Rendering.Gpu.Gl.GlGpuDevice.BeginFrame"/>.
/// <see cref="AcDream.App.Rendering.Gpu.Vk.VulkanGpuDevice.BeginFrame"/>
/// (the raw-GL device's equivalent reset was deleted at Campaign V slice V11).
/// </summary>
private static void DrawRing(IGpuFrame frame, IGpuPassEncoder encoder, List<float> buf)
{

View file

@ -3,10 +3,8 @@ using AcDream.Core.Textures;
using AcDream.Core.World;
using AcDream.Content;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Gl;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using Silk.NET.OpenGL;
using System.Linq;
using PixelFormatId = DatReaderWriter.Enums.PixelFormat;
using SurfaceType = DatReaderWriter.Enums.SurfaceType;
@ -14,22 +12,15 @@ using AcDream.App.Rendering.Residency;
namespace AcDream.App.Rendering;
public sealed unsafe class TextureCache
public sealed class TextureCache
: Wb.IEntityTextureLifetime,
IDisposable
{
private readonly GL? _gl;
private readonly IGpuDevice _device;
private readonly IDatReaderWriter _dats;
private readonly string _diagnosticsDirectory;
// Handle and decoded dimensions are one atomic cache entry. Keeping them
// in separate dictionaries allowed GetOrUpload(surfaceId) followed by the
// sized overload to upload a second GL texture and orphan the first.
private readonly Dictionary<uint, (uint Handle, int Width, int Height)>
_surfacesById = new();
private readonly Dictionary<(uint SurfaceId, uint OrigTextureId), (int Width, int Height)>
_decodedDimensionsByTexture = new();
private uint _magentaHandle;
/// <summary>
/// Campaign V slice V4a: one registered <see cref="IGpuTexture"/> plus its
@ -55,7 +46,6 @@ public sealed unsafe class TextureCache
// GPU texture objects/slots until process exit.
private readonly List<GpuUiTextureEntry> _adhocGpuTextures = new();
private readonly Wb.BindlessSupport? _bindless;
private readonly CompositeTextureArrayCache? _compositeTextures;
private bool _destinationRevealUploadPriority;
@ -105,12 +95,10 @@ public sealed unsafe class TextureCache
// contract), and this convenience overload has no real caller today (both
// production construction sites already target the internal overload
// below) — kept internal rather than deleted to preserve its shape.
internal TextureCache(GL? gl, IGpuDevice device, IDatReaderWriter dats, Wb.BindlessSupport? bindless = null)
internal TextureCache(IGpuDevice device, IDatReaderWriter dats)
: this(
gl,
device,
dats,
bindless,
ImmediateGpuResourceRetirementQueue.Instance,
Path.Combine(
Path.GetTempPath(),
@ -119,142 +107,57 @@ public sealed unsafe class TextureCache
{
}
/// <param name="gl">
/// The GL context, or null on a backend that has none. Campaign V slice V6h:
/// the UI path (<see cref="GetOrUploadRenderSurface"/>, <see cref="UploadRgba8"/>)
/// is entirely <see cref="IGpuDevice"/>-driven and runs on either backend,
/// while the world paths — the legacy <c>Texture2D</c> upload, particle
/// arrays, and the composite/bindless caches — still speak raw GL and are
/// unreachable without it. A null context therefore reaches exactly the same
/// code a null <paramref name="bindless"/> already gated, and every world
/// entry point throws with the slice that owns it named. Removed at V4t,
/// which ports the world texture stack onto the RHI.
/// </param>
internal TextureCache(
GL? gl,
IGpuDevice device,
IDatReaderWriter dats,
Wb.BindlessSupport? bindless,
IGpuResourceRetirementQueue retirementQueue,
string diagnosticsDirectory,
ResidencyBudgetOptions? budgets = null)
{
budgets ??= ResidencyBudgetOptions.Default;
_gl = gl;
if (gl is null && bindless is not null)
{
throw new ArgumentException(
"Bindless composite/particle texture caches require a GL context.",
nameof(bindless));
}
_device = device ?? throw new ArgumentNullException(nameof(device));
_dats = dats;
_bindless = bindless;
ArgumentException.ThrowIfNullOrWhiteSpace(diagnosticsDirectory);
_diagnosticsDirectory = diagnosticsDirectory;
ArgumentNullException.ThrowIfNull(retirementQueue);
if (bindless is not null)
{
var resources = new ResourceCleanupGroup();
CompositeTextureArrayCache? composite = null;
StandaloneBindlessTextureCache? particles = null;
try
{
composite = new CompositeTextureArrayCache(
gl!,
bindless,
WorldDevice,
retirementQueue,
budgets.CompositeUnownedBytes,
budgets.CompositePhysicalBytes);
resources.Add("composite texture cache", composite.Dispose);
particles = new StandaloneBindlessTextureCache(
new ParticleTextureBackend(this),
retirementQueue,
budgets.StandaloneUnownedBytes,
budgets.StandaloneUnownedEntries);
resources.Add("particle texture cache", particles.Dispose);
resources.TransferAll();
}
catch (Exception constructionFailure)
{
resources.RollbackConstructionAndThrow(
"TextureCache construction failed and its child-cache prefix did not cleanly roll back.",
constructionFailure);
}
_compositeTextures = composite;
_particleTextures = particles;
}
else
// Campaign V slice V6l: both owner-scoped caches exist on both arms.
//
// Everything about them that matters — sharing equivalent surfaces
// between owners, the bounded unowned LRU, the metered upload budget,
// and retirement behind the frame-flight fence — is already
// backend-neutral; only how one entry is created and destroyed
// differs, which is exactly what the two backend interfaces are for.
var resources = new ResourceCleanupGroup();
CompositeTextureArrayCache? composite = null;
StandaloneBindlessTextureCache? particles = null;
try
{
// Campaign V slice V6l: both owner-scoped caches exist on both arms.
//
// Everything about them that matters — sharing equivalent surfaces
// between owners, the bounded unowned LRU, the metered upload budget,
// and retirement behind the frame-flight fence — is already
// backend-neutral; only how one entry is created and destroyed
// differs, which is exactly what the two backend interfaces are for.
// RhiCompositeTextureArrayBackend is V6i-2's, built and exercised at
// startup since that slice but with no production consumer until now;
// ParticleRhiTextureBackend is this slice's.
var resources = new ResourceCleanupGroup();
CompositeTextureArrayCache? composite = null;
StandaloneBindlessTextureCache? particles = null;
try
{
composite = new CompositeTextureArrayCache(
new RhiCompositeTextureArrayBackend(device),
retirementQueue,
budgets.CompositeUnownedBytes,
budgets.CompositePhysicalBytes);
resources.Add("composite texture cache", composite.Dispose);
particles = new StandaloneBindlessTextureCache(
new ParticleRhiTextureBackend(this),
retirementQueue,
budgets.StandaloneUnownedBytes,
budgets.StandaloneUnownedEntries);
resources.Add("particle texture cache", particles.Dispose);
resources.TransferAll();
}
catch (Exception constructionFailure)
{
resources.RollbackConstructionAndThrow(
"TextureCache construction failed and its child-cache prefix did not cleanly roll back.",
constructionFailure);
}
_compositeTextures = composite;
_particleTextures = particles;
composite = new CompositeTextureArrayCache(
new RhiCompositeTextureArrayBackend(device),
retirementQueue,
budgets.CompositeUnownedBytes,
budgets.CompositePhysicalBytes);
resources.Add("composite texture cache", composite.Dispose);
particles = new StandaloneBindlessTextureCache(
new ParticleRhiTextureBackend(this),
retirementQueue,
budgets.StandaloneUnownedBytes,
budgets.StandaloneUnownedEntries);
resources.Add("particle texture cache", particles.Dispose);
resources.TransferAll();
}
catch (Exception constructionFailure)
{
resources.RollbackConstructionAndThrow(
"TextureCache construction failed and its child-cache prefix did not cleanly roll back.",
constructionFailure);
}
_compositeTextures = composite;
_particleTextures = particles;
}
/// <summary>
/// The GL context the world texture paths need. Campaign V slice V6h: a
/// Vulkan-composed cache serves the UI path through <see cref="IGpuDevice"/>
/// alone and never reaches here, so a failure names the slice that owns the
/// port rather than dereferencing null.
/// </summary>
private GL Gl => _gl ?? throw new InvalidOperationException(
"This TextureCache owns no GL context: the world texture paths " +
"(Texture2D upload, particle arrays, composite/bindless caches) are " +
"unavailable until Campaign V slice V4t ports them onto the RHI.");
/// <summary>
/// The GL backend's device, for the world texture paths' table
/// registrations (Campaign V slice V4t). Those paths already require a GL
/// context — see <see cref="Gl"/> — so the same construction that makes
/// <see cref="_gl"/> non-null makes this cast sound; a Vulkan-composed
/// cache serves only the UI path through <see cref="IGpuDevice"/> and never
/// reaches here.
/// </summary>
private GlGpuDevice WorldDevice => _device as GlGpuDevice
?? throw new InvalidOperationException(
"This TextureCache's device is not the GL backend's: the world " +
"texture paths intern their bindless handles into GlGpuDevice's " +
"texture table (Campaign V slice V4t).");
internal void RegisterResidencySources(ResidencyManager manager)
{
ArgumentNullException.ThrowIfNull(manager);
@ -286,44 +189,6 @@ public sealed unsafe class TextureCache
BudgetBytes: textures.BudgetBytes);
}
/// <summary>
/// Get or upload the GL texture handle for a Surface id. Returns a
/// 1x1 magenta fallback if the Surface or its RenderSurface chain is
/// missing or uses an unsupported format.
/// </summary>
public uint GetOrUpload(uint surfaceId)
=> GetOrUploadSurfaceCore(surfaceId, out _, out _);
/// <summary>
/// Like <see cref="GetOrUpload(uint)"/> but also returns the decoded
/// pixel dimensions. UI 9-slice geometry needs the source size to
/// compute slice UVs. Cached alongside the handle.
/// </summary>
public uint GetOrUpload(uint surfaceId, out int width, out int height)
=> GetOrUploadSurfaceCore(surfaceId, out width, out height);
private uint GetOrUploadSurfaceCore(uint surfaceId, out int width, out int height)
{
if (_surfacesById.TryGetValue(surfaceId, out var existing))
{
width = existing.Width;
height = existing.Height;
return existing.Handle;
}
DecodedTexture decoded = DecodeFromDats(
surfaceId,
origTextureOverride: null,
paletteOverride: null);
if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_SKY") == "1")
DumpAlphaHistogram(surfaceId, decoded);
uint h = UploadRgba8(decoded);
_surfacesById.Add(surfaceId, (h, decoded.Width, decoded.Height));
width = decoded.Width;
height = decoded.Height;
return h;
}
/// <summary>
/// Upload a UI sprite by its RenderSurface DataId (0x06xxxxxx), decoded
/// DIRECTLY (Portal/HighRes → DecodeRenderSurface) rather than through the
@ -491,20 +356,12 @@ public sealed unsafe class TextureCache
}
/// <summary>
/// The identity a UI upload is accounted under. On GL it is the texture's
/// own GL name — unchanged, so the VRAM ledger and the
/// <c>ACDREAM_DUMP_SURFACES</c> histogram key off exactly what they always
/// did. On any other backend there is no such name, so a descending
/// synthetic counter supplies one; it starts at <c>uint.MaxValue</c> because
/// GL hands out small ascending names and the two spaces share the
/// <c>_uploadMetadata</c> dictionary. The value is a dictionary key and a
/// dedup token only — Campaign V slice V6d removed the last draw-time
/// consumer of a raw GL name, so nothing binds it.
/// The identity a UI upload is accounted under. There is no GL name on the
/// Vulkan-only backend, so a descending synthetic counter supplies one; the
/// value is a dictionary key and a dedup token only — Campaign V slice V6d
/// removed the last draw-time consumer of a raw GL name, so nothing binds it.
/// </summary>
private uint UploadAccountingName(IGpuTexture texture) =>
texture is GlGpuTexture glTexture
? glTexture.GlName
: _nextSyntheticUploadName--;
private uint UploadAccountingName(IGpuTexture texture) => _nextSyntheticUploadName--;
private uint _nextSyntheticUploadName = uint.MaxValue;
@ -521,44 +378,6 @@ public sealed unsafe class TextureCache
GpuAddressMode.Repeat,
MaxAnisotropy: 1f);
/// <summary>
/// Alpha-channel histogram for one decoded texture. Used to diagnose
/// "why are clouds not transparent" — if cloud textures come out with
/// alpha = 1.0 everywhere we know the decode path strips the alpha
/// channel somewhere. Printed once per unique surfaceId under
/// <c>ACDREAM_DUMP_SKY=1</c>. Adds ~2ms per texture upload, negligible.
/// </summary>
private static void DumpAlphaHistogram(uint surfaceId, DecodedTexture decoded)
{
if (decoded.Rgba8.Length == 0 || decoded.Width == 0 || decoded.Height == 0)
{
System.Console.WriteLine($"[tex-alpha] surf=0x{surfaceId:X8} empty");
return;
}
int total = decoded.Rgba8.Length / 4;
// Bucket alpha in 10 bins.
var buckets = new int[10];
int aMin = 255, aMax = 0;
long aSum = 0;
for (int i = 0; i < decoded.Rgba8.Length; i += 4)
{
int a = decoded.Rgba8[i + 3];
if (a < aMin) aMin = a;
if (a > aMax) aMax = a;
aSum += a;
int b = a * 10 / 256;
if (b > 9) b = 9;
buckets[b]++;
}
float aMean = aSum / (float)total / 255f;
var pct = new string[10];
for (int i = 0; i < 10; i++) pct[i] = $"{100.0 * buckets[i] / total:F0}%";
System.Console.WriteLine(
$"[tex-alpha] surf=0x{surfaceId:X8} {decoded.Width}x{decoded.Height} " +
$"a_min={aMin / 255f:F3} a_max={aMax / 255f:F3} a_mean={aMean:F3} " +
$"bins[0-9]={string.Join(",", pct)}");
}
/// <summary>
/// Acquires the exact DAT-decoded one-layer texture array for a live
/// particle emitter. Equivalent surfaces are shared; the cache ownership
@ -588,81 +407,17 @@ public sealed unsafe class TextureCache
origTextureOverride: null,
paletteOverride: null);
// Campaign V slice V6l: the RHI arm has no GL name to intern a bindless
// handle from, so the image is created through IGpuDevice and paired
// with a real sampler object. The decode above is the same one the GL
// arm uses, so the pixels are identical; the shader still samples layer
// zero of a one-layer array, which is what a Texture2D registered into
// the table is on Vulkan (VulkanTextureFormatMapping.SampledViewTypeOf).
if (_gl is null)
return AcquireParticleTextureRhi(textures, ownerId, surfaceId, decoded);
uint name = UploadRgba8AsLayer1Array(decoded);
ulong handle = 0;
try
{
handle = _bindless!.GetResidentHandle(name);
Wb.GLHelpers.ThrowOnResourceError(
Gl,
$"making particle surface 0x{surfaceId:X8} resident");
GpuTextureSlot slot = WorldDevice.RegisterWorldTextureHandle(handle);
var resource = new StandaloneBindlessTextureResource
{
SurfaceId = surfaceId,
Name = name,
Handle = handle,
Slot = slot,
Bytes = checked((long)decoded.Width * decoded.Height * 4L),
};
textures.AddAndAcquire(ownerId, resource);
return slot;
}
catch (Exception residencyFailure)
{
List<Exception>? cleanupFailures = null;
void Attempt(Action cleanup)
{
try { cleanup(); }
catch (Exception ex) { (cleanupFailures ??= []).Add(ex); }
}
bool residencyReleased = handle == 0;
if (handle != 0)
{
Attempt(() =>
{
// Slice V4t: the table entry may or may not have been made
// before the failure. Releasing an unregistered handle is a
// no-op, so this covers both without asking which.
WorldDevice.ReleaseWorldTextureHandle(handle);
_bindless!.MakeNonResident(handle);
Wb.GLHelpers.ThrowOnResourceError(
Gl,
"rolling back particle texture residency");
residencyReleased = true;
});
}
if (residencyReleased)
Attempt(() => DeleteUploadedTexture(name));
if (cleanupFailures is not null)
{
cleanupFailures.Insert(0, residencyFailure);
throw new AggregateException(
"Particle texture residency and rollback both failed.",
cleanupFailures);
}
throw;
}
return AcquireParticleTextureRhi(textures, ownerId, surfaceId, decoded);
}
/// <summary>
/// Campaign V slice V6l: one particle surface as a device texture-table
/// slot, owned by the same emitter-scoped cache the GL arm uses.
/// slot, owned by the same emitter-scoped cache.
///
/// <para>Linear/clamped is the filtering the GL arm's own one-layer array
/// upload sets on itself, and a particle sheet's UVs never leave [0,1] —
/// the quad's own texcoords are the unit square — so the wrap mode is not a
/// visible choice, it is just the safe one.</para>
/// <para>Linear/clamped matches the filtering the deleted GL arm's own
/// one-layer array upload set on itself, and a particle sheet's UVs never
/// leave [0,1] — the quad's own texcoords are the unit square — so the
/// wrap mode is not a visible choice, it is just the safe one.</para>
/// </summary>
private GpuTextureSlot AcquireParticleTextureRhi(
StandaloneBindlessTextureCache textures,
@ -714,8 +469,9 @@ public sealed unsafe class TextureCache
/// Owner-scoped bindless variant for a server-supplied original-texture
/// replacement. Stores compatible composites in a pooled Texture2DArray
/// and returns its resident handle plus the assigned layer. Equivalent
/// composites are shared until their final live owner leaves. Throws if
/// BindlessSupport wasn't provided.
/// composites are shared until their final live owner leaves. Returns
/// <see langword="default"/> (an empty location) if a composite upload
/// can't start or the decoded size can't be prepared this frame.
/// </summary>
internal BindlessTextureLocation GetOrUploadWithOrigTextureOverrideBindless(
uint ownerLocalId,
@ -750,7 +506,8 @@ public sealed unsafe class TextureCache
/// top of the texture's default palette before decoding, stores compatible
/// composites in a pooled Texture2DArray, and returns its resident handle
/// plus the assigned layer. Structural identity is computed once per entity.
/// Throws if BindlessSupport wasn't provided to the constructor.
/// Returns <see langword="default"/> (an empty location) if a composite
/// upload can't start or the decoded size can't be prepared this frame.
/// </summary>
internal BindlessTextureLocation GetOrUploadWithPaletteOverrideBindless(
uint ownerLocalId,
@ -853,14 +610,6 @@ public sealed unsafe class TextureCache
EnsureCompositeTexturesAvailable().ReleaseOwner(localEntityId);
}
private void EnsureBindlessAvailable()
{
if (_bindless is null)
throw new InvalidOperationException(
"TextureCache constructed without BindlessSupport — cannot generate bindless handles. " +
"WbDrawDispatcher requires the bindless-aware ctor overload (pass non-null BindlessSupport).");
}
/// <summary>
/// Campaign V slice V6l: no longer gated on bindless. The composite cache is
/// constructed on both arms — V6i-2's RHI backend is what serves the one
@ -880,29 +629,9 @@ public sealed unsafe class TextureCache
_particleTextures ?? throw new InvalidOperationException(
"This TextureCache owns no standalone particle texture cache.");
private sealed class ParticleTextureBackend(TextureCache owner)
: IStandaloneBindlessTextureBackend
{
public void MakeNonResident(StandaloneBindlessTextureResource resource)
{
// Slice V4t: retire the table entry before its handle stops being
// resident. Idempotent, so a retried release stays correct.
owner.WorldDevice.ReleaseWorldTextureHandle(resource.Handle);
owner._bindless!.MakeNonResident(resource.Handle);
Wb.GLHelpers.ThrowOnResourceError(
owner.Gl,
$"releasing particle texture handle {resource.Handle}");
}
public void Delete(StandaloneBindlessTextureResource resource)
=> owner.DeleteUploadedTexture(resource.Name);
}
/// <summary>
/// Campaign V slice V6l: the same ownership boundary on a backend with no
/// bindless handles. The table slot is released first and the image second,
/// which is the same order the GL arm uses and for the same reason — a
/// submitted-but-unretired frame may still sample the slot, and
/// Campaign V slice V6l: the table slot is released first and the image
/// second — a submitted-but-unretired frame may still sample the slot, and
/// <see cref="IGpuDevice.ReleaseTextureSlot"/> is what defers its reuse.
/// </summary>
private sealed class ParticleRhiTextureBackend(TextureCache owner)
@ -1043,7 +772,6 @@ public sealed unsafe class TextureCache
bucketsByTriple[tripleKey] = bucketsByTriple.GetValueOrDefault(tripleKey) + 1;
}
foreach (var kv in _surfacesById) Emit(kv.Key, kv.Value.Handle);
_particleTextures?.VisitEntries(resource => Emit(resource.SurfaceId, resource.Name));
_compositeTextures?.VisitEntries((surfaceId, width, height) =>
{
@ -1191,101 +919,6 @@ public sealed unsafe class TextureCache
return UiTextureTableHandle.FromSlot(entry.Slot);
}
private uint UploadRgba8(DecodedTexture decoded, bool nearest = false)
{
uint tex = Gl.GenTexture();
if (tex == 0)
throw new InvalidOperationException("OpenGL did not create a 2D texture.");
try
{
Gl.BindTexture(TextureTarget.Texture2D, tex);
fixed (byte* p = decoded.Rgba8)
Gl.TexImage2D(
TextureTarget.Texture2D,
0,
InternalFormat.Rgba8,
(uint)decoded.Width,
(uint)decoded.Height,
0,
PixelFormat.Rgba,
PixelType.UnsignedByte,
p);
// Point (nearest) sampling for pixel-exact UI text — bilinear softens the dat
// font's small glyphs. Other surfaces use bilinear.
int filter = nearest ? (int)TextureMinFilter.Nearest : (int)TextureMinFilter.Linear;
Gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, filter);
Gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, filter);
Gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
Gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
Wb.GLHelpers.ThrowOnResourceError(
Gl,
$"uploading 2D RGBA8 texture {decoded.Width}x{decoded.Height}");
TrackUploadedTexture(tex, decoded.Width, decoded.Height);
return tex;
}
catch
{
Gl.DeleteTexture(tex);
throw;
}
finally
{
Gl.BindTexture(TextureTarget.Texture2D, 0);
}
}
/// <summary>
/// Variant of <see cref="UploadRgba8"/> that uploads pixel data as a 1-layer
/// Texture2DArray. Required by the WB modern rendering path which samples via
/// sampler2DArray in its bindless shader. Pixel data is identical.
/// </summary>
private uint UploadRgba8AsLayer1Array(DecodedTexture decoded)
{
uint tex = Gl.GenTexture();
if (tex == 0)
throw new InvalidOperationException("OpenGL did not create a one-layer texture array.");
try
{
Gl.BindTexture(TextureTarget.Texture2DArray, tex);
fixed (byte* p = decoded.Rgba8)
Gl.TexImage3D(
TextureTarget.Texture2DArray,
0,
InternalFormat.Rgba8,
(uint)decoded.Width,
(uint)decoded.Height,
depth: 1,
border: 0,
PixelFormat.Rgba,
PixelType.UnsignedByte,
p);
Gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
Gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
Gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
Gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
Wb.GLHelpers.ThrowOnResourceError(
Gl,
$"uploading one-layer RGBA8 array {decoded.Width}x{decoded.Height}");
TrackUploadedTexture(tex, decoded.Width, decoded.Height);
return tex;
}
catch
{
Gl.DeleteTexture(tex);
throw;
}
finally
{
Gl.BindTexture(TextureTarget.Texture2DArray, 0);
}
}
private void TrackUploadedTexture(uint name, int width, int height)
{
_uploadMetadata[name] = (width, height, "RGBA8_DECODED");
@ -1294,19 +927,10 @@ public sealed unsafe class TextureCache
Wb.GpuMemoryTracker.TrackAllocation(bytes, Wb.GpuResourceType.Texture);
}
private void DeleteUploadedTexture(uint name)
{
Gl.DeleteTexture(name);
Wb.GLHelpers.ThrowOnResourceError(Gl, $"deleting uploaded texture {name}");
UntrackUploadedTexture(name);
}
/// <summary>
/// Memory-tracking bookkeeping only, without a raw GL delete — used for
/// the Campaign V slice V4a UI-path <see cref="IGpuTexture"/> entries,
/// whose GL name is released by <see cref="IGpuTexture.Dispose"/> through
/// the device's own retirement queue rather than by
/// <see cref="DeleteUploadedTexture"/>.
/// Memory-tracking bookkeeping only — used for every <see cref="IGpuTexture"/>
/// entry, whose GPU resource is released by <see cref="IGpuTexture.Dispose"/>
/// through the device's own retirement queue.
/// </summary>
private void UntrackUploadedTexture(uint name)
{
@ -1328,17 +952,6 @@ public sealed unsafe class TextureCache
_paletteIndexedByTexture.Clear();
// Legacy Texture2D textures.
foreach (var entry in _surfacesById.Values)
DeleteUploadedTexture(entry.Handle);
_surfacesById.Clear();
if (_magentaHandle != 0)
{
DeleteUploadedTexture(_magentaHandle);
_magentaHandle = 0;
}
// RenderSurface (UI sprite) textures — Campaign V slice V4a: each
// entry's IGpuTexture.Dispose() releases the underlying GL name
// through the device's own retirement queue, so only the memory-

View file

@ -1,31 +0,0 @@
namespace AcDream.App.Rendering;
internal static class TrackedTextureConstruction
{
public static uint Create(
GlTextureConstructionTransaction transaction,
Action<uint> initialize)
{
ArgumentNullException.ThrowIfNull(transaction);
ArgumentNullException.ThrowIfNull(initialize);
uint texture = transaction.Allocate();
initialize(texture);
return texture;
}
public static uint Create(
GlTextureConstructionTransaction transaction,
Silk.NET.OpenGL.GL gl,
string context,
Action<uint> initialize)
{
ArgumentNullException.ThrowIfNull(transaction);
ArgumentNullException.ThrowIfNull(gl);
ArgumentException.ThrowIfNullOrWhiteSpace(context);
ArgumentNullException.ThrowIfNull(initialize);
uint texture = transaction.Allocate();
GlResourceCommand.Execute(gl, context, () => initialize(texture));
return texture;
}
}

View file

@ -1,132 +0,0 @@
using Silk.NET.OpenGL;
using Silk.NET.OpenGL.Extensions.ARB;
using AcDream.App.Rendering;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Thin wrapper around <see cref="ArbBindlessTexture"/> + capability detection
/// for the modern rendering path. Constructed once at startup via
/// <see cref="TryCreate"/>, which returns false if the extension isn't present.
/// </summary>
public sealed class BindlessSupport
{
private readonly GL _gl;
private readonly ArbBindlessTexture _ext;
private BindlessSupport(GL gl, ArbBindlessTexture extension)
{
_gl = gl;
_ext = extension;
}
public static bool TryCreate(GL gl, out BindlessSupport? support)
{
if (gl.TryGetExtension<ArbBindlessTexture>(out var ext))
{
support = new BindlessSupport(gl, ext);
return true;
}
support = null;
return false;
}
/// <summary>Get a 64-bit bindless handle for the texture and make it resident.
/// Idempotent: handle is the same for a given texture name.</summary>
public ulong GetResidentHandle(uint textureName)
{
ulong h = GlResourceCommand.Execute(
_gl,
$"get bindless handle for texture {textureName}",
() => _ext.GetTextureHandle(textureName));
if (h == 0)
throw new InvalidOperationException(
$"OpenGL returned no bindless handle for texture {textureName}.");
bool resident = GlResourceCommand.Execute(
_gl,
$"query bindless handle {h} residency",
() => _ext.IsTextureHandleResident(h));
if (!resident)
{
GlResourceCommand.Execute(
_gl,
$"make bindless handle {h} resident",
() => _ext.MakeTextureHandleResident(h));
}
return h;
}
/// <summary>
/// Get a 64-bit bindless handle combining a texture with an EXPLICIT
/// sampler object (rather than the texture's own baked sampler state) and
/// make it resident. Idempotent per (texture, sampler) pair.
///
/// Added for Campaign V slice V1's <c>GlGpuDevice.RegisterTexture</c>,
/// which registers a (texture, sampler) pair per the RHI contract — "the
/// same texture registered with two samplers occupies two slots." The
/// texture-only <see cref="GetResidentHandle(uint)"/> above cannot express
/// that; <c>ManagedGLTextureArray</c> already calls the equivalent
/// <c>ArbBindlessTexture.GetTextureSamplerHandle</c> directly through
/// <c>OpenGLGraphicsDevice.BindlessExtension</c>, so this simply exposes
/// the same GL entry point through this class for the RHI's use.
/// </summary>
public ulong GetResidentHandle(uint textureName, uint samplerName)
{
ulong h = GlResourceCommand.Execute(
_gl,
$"get bindless handle for texture {textureName} + sampler {samplerName}",
() => _ext.GetTextureSamplerHandle(textureName, samplerName));
if (h == 0)
{
throw new InvalidOperationException(
$"OpenGL returned no bindless handle for texture {textureName} + sampler {samplerName}.");
}
bool resident = GlResourceCommand.Execute(
_gl,
$"query bindless handle {h} residency",
() => _ext.IsTextureHandleResident(h));
if (!resident)
{
GlResourceCommand.Execute(
_gl,
$"make bindless handle {h} resident",
() => _ext.MakeTextureHandleResident(h));
}
return h;
}
/// <summary>Release residency for a handle. Call before deleting the underlying texture.</summary>
public void MakeNonResident(ulong handle)
{
bool resident = GlResourceCommand.Execute(
_gl,
$"query bindless handle {handle} residency before release",
() => _ext.IsTextureHandleResident(handle));
if (!resident)
return;
GlResourceCommand.Execute(
_gl,
$"make bindless handle {handle} non-resident",
() => _ext.MakeTextureHandleNonResident(handle));
}
// Phase N.5b note: a `SetSamplerHandleUniform` wrapper was added in T6
// and removed when terrain rendering surfaced GL_INVALID_OPERATION on
// NVIDIA Windows for the `uniform sampler2DArray` + glProgramUniformHandleARB
// combination. The replacement pattern (uvec2 handle uniform + GLSL
// sampler-from-handle constructor — see terrain_modern.frag) lives at the
// call site via plain `_gl.ProgramUniform2(program, loc, low, high)`. If
// you re-introduce a sampler-handle helper, restrict it to drivers known
// to accept the direct sampler-uniform path.
/// <summary>Detect <c>GL_ARB_shader_draw_parameters</c> in addition to bindless.
/// N.5's vertex shader uses <c>gl_BaseInstanceARB</c> and <c>gl_DrawIDARB</c>
/// from this extension.</summary>
public bool HasShaderDrawParameters(GL gl)
{
return gl.IsExtensionPresent("GL_ARB_shader_draw_parameters");
}
}

View file

@ -25,20 +25,15 @@ using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using DatReaderWriter.Enums;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering.Wb;
public sealed unsafe partial class EnvCellRenderer :
public sealed partial class EnvCellRenderer :
IDisposable,
IEnvCellLandblockPublisher
{
private readonly object _publicationOwner = new();
// Campaign V slice V6j: null on the RHI arm. Every GL statement below is
// reached only when this is non-null; the encoder arm lives in
// EnvCellRenderer.Rhi.cs and the GL arm is unchanged.
private readonly GL? _gl;
private readonly ObjectMeshManager _meshManager;
private readonly WbFrustum _frustum;
@ -52,14 +47,6 @@ public sealed unsafe partial class EnvCellRenderer :
private readonly object _renderLock = new();
private EnvCellVisibilitySnapshot _activeSnapshot = new();
// Shader (set by caller via Initialize).
// Uses acdream's legacy Shader type (not WB's GLSLShader) to match the
// existing wire-in pattern in GameWindow.cs where _meshShader is loaded
// for mesh_modern.{vert,frag} and shared across multiple consumers.
// API mapping: Bind() -> Use(), SetUniform(s, int) -> SetInt(s, int),
// SetUniform(s, Vector4) -> SetVec4(s, Vector4).
private AcDream.App.Rendering.Shader? _shader;
// Phase U.4 root-cause fix: the view-projection captured in PrepareRenderBatches,
// re-uploaded by Render() so the cell-shell pass is self-contained and does NOT
// inherit WbDrawDispatcher's uViewProjection (which the opaque pass would read one
@ -84,35 +71,22 @@ public sealed unsafe partial class EnvCellRenderer :
// Modern-MDI scratch buffers (single slot — we re-upload every frame).
// WB BaseObjectRenderManager.cs:43-48: _scratchMdiCommandBuffers, _scratchModernBatchBuffers, _modernInstanceBuffers
// We collapse the ring-of-3 to a single slot since we have no persistent/consolidated draws.
private uint _mdiCommandBuffer;
private int _mdiCommandCapacity;
private uint _modernInstanceBuffer;
private int _modernInstanceCapacity;
private uint _modernBatchBuffer;
private int _modernBatchCapacity;
// mesh_modern.vert's SSBO InstanceData is only mat4 transform. The CPU
// InstanceData below also carries CellId/Flags for filtering, so upload a
// packed transform array instead of the 80-byte CPU struct.
private Matrix4x4[] _gpuInstanceTransforms = Array.Empty<Matrix4x4>();
// Phase U.3: per-instance clip-slot SSBO (binding=3), parallel to
// _modernInstanceBuffer. One uint per instance selecting its CellClip slot,
// indexed by the same BaseInstance + gl_InstanceID the shader uses for
// binding=0. ALL ZEROS in U.3 ⇒ slot 0 ⇒ no-clip. U.4 populates real slots.
private uint _clipSlotBuffer;
private int _clipSlotCapacity;
// Phase U.3: per-instance clip-slot data, parallel to _gpuInstanceTransforms.
// One uint per instance selecting its CellClip slot, indexed by the same
// BaseInstance + gl_InstanceID the shader uses for binding=0. ALL ZEROS ⇒
// slot 0 ⇒ no-clip.
private uint[] _clipSlotData = Array.Empty<uint>();
// A7 Fix D (D-2): this renderer owns its lighting (self-contained GL state,
// like uViewProjection) instead of reading the SSBO 4/5 WbDrawDispatcher last
// left bound. binding=4 = global point-light snapshot (same data/indices as the
// dispatcher, via GlobalLightPacker); binding=5 = 8 int indices per instance.
private uint _globalLightsSsbo; // binding=4
private int _globalLightsCapacity;
// A7 Fix D (D-2): this renderer owns its lighting (self-contained state,
// like uViewProjection) instead of reading whatever WbDrawDispatcher last
// bound. Global point-light snapshot (same data/indices as the dispatcher,
// via GlobalLightPacker) plus 8 int indices per instance.
private float[] _globalLightData = new float[AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * 16];
private uint _instLightSetSsbo; // binding=5
private int _instLightSetCapacity;
private int[] _lightSetData = new int[1024 * AcDream.Core.Lighting.LightManager.MaxLightsPerObject];
private System.Collections.Generic.IReadOnlyList<AcDream.Core.Lighting.LightSource>? _pointSnapshot;
private sealed class CachedCellLightSet
@ -125,51 +99,17 @@ public sealed unsafe partial class EnvCellRenderer :
private readonly List<uint> _cellLightRemovalScratch = new();
private int _lightFrameGeneration;
private sealed class DynamicBufferSet
{
public uint MdiCommandBuffer;
public uint ModernInstanceBuffer;
public uint ModernBatchBuffer;
public uint ClipSlotBuffer;
public uint GlobalLightsSsbo;
public uint InstanceLightSetSsbo;
public int MdiCommandCapacity;
public int ModernInstanceCapacity;
public int ModernBatchCapacity;
public int ClipSlotCapacity;
public int GlobalLightsCapacity;
public int InstanceLightSetCapacity;
}
private readonly List<DynamicBufferSet>[] _dynamicBufferSetsByFrame =
[[], [], []];
// Per-GPU-fenced-frame-slot draw bookkeeping.
private int _dynamicFrameSlot;
private int _dynamicBufferSetCursor;
private bool _dynamicFrameStarted;
private DynamicBufferSet? _activeDynamicBufferSet;
internal int DynamicBufferSetCount =>
_dynamicBufferSetsByFrame.Sum(frameSets => frameSets.Count);
// Phase U.3: SHARED per-cell clip-region SSBO (binding=2) handed in via
// SetClipRegionSsbo (the GameWindow-level ClipFrame buffer). When 0, we bind
// our own one-slot no-clip fallback so the shader never reads an unbound SSBO.
private uint _sharedClipRegionSsbo;
private uint _fallbackClipRegionSsbo;
// Campaign V slice V4t (2026-07-28): the interim per-renderer
// GlBindlessHandleTable is retired. ObjectRenderBatch already carries the
// device's own GpuTextureSlot, so this renderer shares WbDrawDispatcher's
// table — the device's — and only has to flush and bind it before its own
// raw-GL draws. That also removes the V2 caveat that two renderers could
// legitimately number the same texture differently: there is now one
// numbering, and it is the one the mesh manager assigned at upload.
private AcDream.App.Rendering.Gpu.Gl.GlGpuDevice WorldTextureTable =>
(_meshManager
?? throw new InvalidOperationException(
"EnvCellRenderer was constructed without a mesh manager: its texture " +
"slots come from that manager's GL device table (Campaign V slice V4t)."))
.WorldTextureTable;
/// <summary>
/// The dynamic per-frame-slot SSBO pool this used to report was raw-GL-only
/// bookkeeping, deleted with that arm at Campaign V slice V11. The RHI arm
/// allocates its storage from the GPU frame's own upload ring instead, so
/// there is no separate pool to count.
/// </summary>
internal int DynamicBufferSetCount => 0;
// Reusable scratch arrays — avoid per-frame allocation.
// WB BaseObjectRenderManager.cs:58-59: private DrawElementsIndirectCommand[] _commands = Array.Empty<...>()
@ -197,11 +137,6 @@ public sealed unsafe partial class EnvCellRenderer :
private readonly Dictionary<ulong, List<InstanceData>> _activeSnapshotGlobalGroups = new();
private readonly List<ulong> _activeSnapshotGlobalGfxObjIds = new();
// Static render-state tracking — matches WB BaseObjectRenderManager.cs:24-28.
// Shared across all manager instances on the same GL context.
private static uint _currentVao;
private static CullMode? _currentCullMode;
public bool NeedsPrepare { get; private set; } = true;
// --- Prepare gate (2026-07-24) -------------------------------------------
@ -302,31 +237,19 @@ public sealed unsafe partial class EnvCellRenderer :
}
// ---------------------------------------------------------------------------
// Constructor + Initialize
// Constructor
// Campaign V slice V11: the raw-GL constructor + Initialize(Shader) two-step
// are deleted. EnvCellRenderer.Rhi.cs's constructor is now the class's sole
// constructor — it builds the three shell pipelines itself, so there is no
// second initialization step.
// ---------------------------------------------------------------------------
public EnvCellRenderer(GL gl, ObjectMeshManager meshManager, WbFrustum frustum)
{
_gl = gl;
_meshManager = meshManager;
_frustum = frustum;
}
public void Initialize(AcDream.App.Rendering.Shader shader)
{
_shader = shader;
_initialized = true;
}
/// <summary>Resets the per-frame submission cursor for the GPU-fenced slot.</summary>
public void BeginFrame(int frameSlot)
{
if ((uint)frameSlot >= (uint)_dynamicBufferSetsByFrame.Length)
throw new ArgumentOutOfRangeException(nameof(frameSlot));
ArgumentOutOfRangeException.ThrowIfNegative(frameSlot);
_dynamicFrameSlot = frameSlot;
_dynamicBufferSetCursor = 0;
_dynamicFrameStarted = true;
_activeDynamicBufferSet = null;
if (++_lightFrameGeneration == 0)
{
_cellLightSetCache.Clear();
@ -334,15 +257,6 @@ public sealed unsafe partial class EnvCellRenderer :
}
}
/// <summary>
/// Phase U.3: hand the renderer the SHARED per-cell clip-region SSBO
/// (binding=2) created by <see cref="ClipFrame.UploadShared"/>. The renderer
/// re-binds it to binding=2 immediately before its MDI. Pass 0 to fall back to
/// the internal one-slot no-clip region buffer.
/// </summary>
public void SetClipRegionSsbo(uint sharedClipRegionSsbo)
=> _sharedClipRegionSsbo = sharedClipRegionSsbo;
// Phase U.4: per-frame cellId→CellClip-slot map for the cell shells. When
// non-null, RenderModernMDIInternal writes instanceClipSlot[i] =
// _cellIdToSlot[allInstances[i].CellId] so each cell's shell instances are
@ -975,18 +889,14 @@ public sealed unsafe partial class EnvCellRenderer :
HashSet<uint>? filter,
IReadOnlyList<uint>? orderedCellIds)
{
// WB EnvCellRenderManager.cs:400:
// WB EnvCellRenderManager.cs:400: the RHI arm's three pipelines are built
// at construction (see EnvCellRenderer.Rhi.cs), so _initialized alone
// answers whether this renderer is ready to draw.
if (!_initialized) return;
// Campaign V slice V6j: the RHI arm has no linked program to check — the
// pipeline it draws with was built at construction and the same readiness
// question is answered by _initialized alone.
if (_gl is not null && (_shader is null || _shader.Program == 0)) return;
lock (_renderLock)
{
var snapshot = _activeSnapshot;
// WB EnvCellRenderManager.cs:403-404:
_shader?.Use();
// FIX 2026-05-28 (pool aliasing root cause): mirror WB
// EnvCellRenderManager.cs:405 — restore the pool cursor to the
// high-water mark Prepare's merge phase reached, so any
@ -997,43 +907,6 @@ public sealed unsafe partial class EnvCellRenderer :
// mid-Render. See docs/research/2026-05-28-a8-env-cell-renderer-audit-findings.md.
_poolIndex = snapshot.PostPreparePoolIndex;
// FIX 2026-05-28: invalidate static GL-state caches at start of Render.
// Mirrors WB EnvCellRenderManager.cs:404-410:
// CurrentVAO = 0; CurrentIBO = 0; CurrentAtlas = 0;
// CurrentInstanceBuffer = 0; CurrentCullMode = null;
//
// These caches let SetCullMode / BindVertexArray skip redundant GL
// calls when the state is already correct. BUT: between two Render()
// invocations, OTHER consumers (WbDrawDispatcher, terrain, the
// RenderInsideOutAcdream stencil pipeline) change the actual GL
// state without updating these caches. The cache then lies, and
// the per-batch SetCullMode in RenderModernMDIInternal skips its
// glCullFace call — leaving stale cull state from the prior
// consumer. For a cottage with mixed CullMode batches, half the
// walls end up culled and the user sees "missing walls".
//
// Forcing the cache to null/0 at entry guarantees each Render call
// re-establishes the GL state it expects.
_currentVao = 0;
_currentCullMode = null;
// WB EnvCellRenderManager.cs:406-409: uniform state setup.
_shader?.SetInt("uRenderPass", (int)renderPass);
_shader?.SetInt("uFilterByCell", 0);
_shader?.SetInt("uLightingMode", 1); // A7 Fix D D-3/D-4: EnvCell bake (wrap points, no sun)
// #176 stripe-hunt isolation (ACDREAM_LIGHT_DEBUG) — throwaway diagnostic.
_shader?.SetInt("uLightDebug", AcDream.Core.Rendering.RenderingDiagnostics.LightDebugMode);
// Phase U.4 ROOT-CAUSE FIX (cell-shell flicker / "transparent walls when
// moving"): upload uViewProjection HERE rather than inheriting it from
// WbDrawDispatcher. The opaque shell pass runs BEFORE the dispatcher's
// Draw (GameWindow ~7411 vs ~7418, the only other setter), so without
// this the opaque shells used the PREVIOUS frame's matrix — a stale
// gl_Position against this frame's clip planes → pose-dependent clipping,
// worst while moving. Same self-contained-GL-state precedent as the
// 2026-05-28 cull-state cache fix above.
_shader?.SetMatrix4("uViewProjection", _lastViewProjection);
List<InstanceData> allInstances = _renderInstances;
List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> drawCalls =
_renderDrawCalls;
@ -1146,7 +1019,6 @@ public sealed unsafe partial class EnvCellRenderer :
if (_drawCallRanges.Count == 0 && drawCalls.Count > 0)
_drawCallRanges.Add(new DrawCallRange(0, drawCalls.Count));
RenderModernMDIInternal(
_shader,
drawCalls,
allInstances,
_drawCallRanges,
@ -1155,20 +1027,6 @@ public sealed unsafe partial class EnvCellRenderer :
// WB EnvCellRenderManager.cs:486-510: selection/hover highlights — DROPPED (no editor state).
// WB EnvCellRenderManager.cs:506-509: cleanup.
_shader?.SetVec4("uHighlightColor", new System.Numerics.Vector4(0, 0, 0, 0));
_shader?.SetInt("uRenderPass", (int)renderPass);
_gl?.BindVertexArray(0);
_currentVao = 0;
// No cull restore at exit, matching WB's manager pattern: the
// last SetCullMode call reflects actual GL state, and the next
// Render call invalidates `_currentCullMode` before issuing its
// own per-batch state. The Landblock->None override below can
// intentionally leave cull disabled for the following IndoorPass,
// preserving the shipped Gate #5 baseline while deeper evidence is
// gathered.
// Update frame stats for probe emission at the call site.
_lastFrameStats.CellsRendered = orderedCellIds?.Count
?? filter?.Count
@ -1285,58 +1143,6 @@ public sealed unsafe partial class EnvCellRenderer :
// issues glMultiDrawElementsIndirect.
// ---------------------------------------------------------------------------
private void ActivateNextDynamicBufferSet()
{
if (!_dynamicFrameStarted)
throw new InvalidOperationException("BeginFrame must be called before drawing EnvCells.");
List<DynamicBufferSet> slotSets = _dynamicBufferSetsByFrame[_dynamicFrameSlot];
if (_dynamicBufferSetCursor == slotSets.Count)
slotSets.Add(CreateDynamicBufferSet());
DynamicBufferSet set = slotSets[_dynamicBufferSetCursor++];
_activeDynamicBufferSet = set;
_mdiCommandBuffer = set.MdiCommandBuffer;
_modernInstanceBuffer = set.ModernInstanceBuffer;
_modernBatchBuffer = set.ModernBatchBuffer;
_clipSlotBuffer = set.ClipSlotBuffer;
_globalLightsSsbo = set.GlobalLightsSsbo;
_instLightSetSsbo = set.InstanceLightSetSsbo;
_mdiCommandCapacity = set.MdiCommandCapacity;
_modernInstanceCapacity = set.ModernInstanceCapacity;
_modernBatchCapacity = set.ModernBatchCapacity;
_clipSlotCapacity = set.ClipSlotCapacity;
_globalLightsCapacity = set.GlobalLightsCapacity;
_instLightSetCapacity = set.InstanceLightSetCapacity;
}
private DynamicBufferSet CreateDynamicBufferSet()
{
var set = new DynamicBufferSet();
try
{
set.MdiCommandBuffer = TrackedGlResource.CreateBuffer(_gl!, "creating EnvCell MDI buffer");
set.ModernInstanceBuffer = TrackedGlResource.CreateBuffer(_gl!, "creating EnvCell instance SSBO");
set.ModernBatchBuffer = TrackedGlResource.CreateBuffer(_gl!, "creating EnvCell batch SSBO");
set.ClipSlotBuffer = TrackedGlResource.CreateBuffer(_gl!, "creating EnvCell clip-slot SSBO");
set.GlobalLightsSsbo = TrackedGlResource.CreateBuffer(_gl!, "creating EnvCell global-light SSBO");
set.InstanceLightSetSsbo = TrackedGlResource.CreateBuffer(_gl!, "creating EnvCell light-set SSBO");
return set;
}
catch (Exception creationFailure)
{
try { DeleteDynamicBufferSet(set); }
catch (Exception cleanupFailure)
{
throw new AggregateException(
"EnvCell dynamic-buffer creation and rollback failed.",
creationFailure,
cleanupFailure);
}
throw;
}
}
private void RebuildUnfilteredGroups(EnvCellVisibilitySnapshot snapshot)
{
foreach (List<InstanceData> instances in _activeSnapshotGlobalGroups.Values)
@ -1359,59 +1165,7 @@ public sealed unsafe partial class EnvCellRenderer :
}
}
private void DeleteDynamicBufferSet(DynamicBufferSet set)
{
List<Exception>? failures = null;
void Attempt(uint buffer, long bytes, string name)
{
try { TrackedGlResource.DeleteBuffer(_gl!, buffer, bytes, $"deleting {name}"); }
catch (Exception ex) { (failures ??= []).Add(ex); }
}
Attempt(
set.MdiCommandBuffer,
(long)set.MdiCommandCapacity * sizeof(DrawElementsIndirectCommand),
"EnvCell MDI buffer");
Attempt(
set.ModernInstanceBuffer,
(long)set.ModernInstanceCapacity * sizeof(Matrix4x4),
"EnvCell instance SSBO");
Attempt(
set.ModernBatchBuffer,
(long)set.ModernBatchCapacity * sizeof(ModernBatchData),
"EnvCell batch SSBO");
Attempt(set.ClipSlotBuffer, (long)set.ClipSlotCapacity * sizeof(uint), "EnvCell clip-slot SSBO");
Attempt(
set.GlobalLightsSsbo,
(long)set.GlobalLightsCapacity
* AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight
* sizeof(float),
"EnvCell global-light SSBO");
Attempt(
set.InstanceLightSetSsbo,
(long)set.InstanceLightSetCapacity
* AcDream.Core.Lighting.LightManager.MaxLightsPerObject
* sizeof(int),
"EnvCell light-set SSBO");
if (failures is not null)
throw new AggregateException("One or more EnvCell dynamic buffers failed to delete.", failures);
}
private void PersistActiveDynamicBufferCapacities()
{
DynamicBufferSet set = _activeDynamicBufferSet
?? throw new InvalidOperationException("No dynamic EnvCell buffer set is active.");
set.MdiCommandCapacity = _mdiCommandCapacity;
set.ModernInstanceCapacity = _modernInstanceCapacity;
set.ModernBatchCapacity = _modernBatchCapacity;
set.ClipSlotCapacity = _clipSlotCapacity;
set.GlobalLightsCapacity = _globalLightsCapacity;
set.InstanceLightSetCapacity = _instLightSetCapacity;
}
private void RenderModernMDIInternal(
AcDream.App.Rendering.Shader? shader,
List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> drawCalls,
List<InstanceData> allInstances,
IReadOnlyList<DrawCallRange> drawCallRanges,
@ -1423,26 +1177,11 @@ public sealed unsafe partial class EnvCellRenderer :
int passIdx = (int)renderPass;
if (passIdx < 0 || passIdx > 2) return;
// §4 outdoor full-world flap (2026-06-10): hoisted from below the SSBO uploads.
// Without the global VAO nothing can draw, and returning AFTER the pass state
// was established leaked it (same early-out shape as the totalDraws==0 leak —
// see the comment on the state-establish block below).
// Campaign V slice V6j: the RHI arm has no vertex array — the pipeline
// owns one shaped by GpuVertexLayout.WorldMesh — so its readiness test is
// the backend-neutral HasStores the arena publishes (V6i-3).
var globalVao = _meshManager.GlobalBuffer?.VAO ?? 0u;
if (_gl is not null)
{
if (globalVao == 0) return;
}
else if (_meshManager.GlobalBuffer is not { HasStores: true })
{
// the backend-neutral HasStores flag the mesh arena publishes (V6i-3).
if (_meshManager.GlobalBuffer is not { HasStores: true })
return;
}
// WB BaseObjectRenderManager.cs:715-716:
shader?.Use();
shader?.SetInt("uFilterByCell", 0);
// WB BaseObjectRenderManager.cs:718-740: count the pass-filtered batches.
// A normal render has one range. The ordered transparent-shell path has
@ -1479,120 +1218,16 @@ public sealed unsafe partial class EnvCellRenderer :
// WB BaseObjectRenderManager.cs:743:
if (totalDraws == 0) return;
int uniqueInstanceCount = allInstances.Count;
// Campaign V slice V6j: the encoder arm owns no buffer pool and no
// imperative state bracket. Every per-frame section is a ring slice, so
// there is nothing to activate or grow, and blend plus depth-write are
// baked into the three shell pipelines rather than set here.
if (_gl is not null)
{
ActivateNextDynamicBufferSet();
// Phase U.4 ROOT-CAUSE FIX (cell-shell "transparent walls / only bluish
// background, flickering when moving"): establish this pass's BLEND + DepthMask
// state OURSELVES rather than inheriting it. Mirror the working WbDrawDispatcher
// passes (Disable(Blend)+DepthMask(true) opaque; Enable(Blend)+DepthMask(false)
// transparent). Restored to opaque defaults at the end of the draw loop so a
// Transparent pass can't leak into later draws.
//
// §4 outdoor full-world flap fix (2026-06-10): this block MOVED below the
// totalDraws==0 early-out above. It used to run before the batch grouping, so a
// Transparent pass over a cell whose batches are ALL opaque (a plain cottage
// interior) set Blend-on/DepthMask-off and then returned at the count check
// WITHOUT reaching the restore. The frame ended with dmask=0; the NEXT frame's
// glClear(DEPTH) silently no-oped (depth clears honor glDepthMask), every world
// fragment failed GL_LESS against its own previous-frame depth ghost, and the
// whole screen dropped to the fog-tinted clear color — onset-locked to the
// building-flood merge (the first frame a flooded building shell draws), holding
// until camera rotation dropped the cell from the flood. From here down every
// path reaches the end-of-pass restore.
if (renderPass == WbRenderPass.Transparent)
{
_gl.Enable(EnableCap.Blend);
_gl.DepthMask(false);
}
else
{
_gl.Disable(EnableCap.Blend);
_gl.DepthMask(true);
}
// WB BaseObjectRenderManager.cs:745-759: resize buffers if needed.
if (totalDraws > _mdiCommandCapacity)
{
int grownMdiCapacity = Math.Max(_mdiCommandCapacity * 2, totalDraws);
TrackedGlResource.AllocateBufferStorage(
_gl,
GLEnum.DrawIndirectBuffer,
_mdiCommandBuffer,
(long)_mdiCommandCapacity * sizeof(DrawElementsIndirectCommand),
(long)grownMdiCapacity * sizeof(DrawElementsIndirectCommand),
GLEnum.DynamicDraw,
$"growing EnvCell MDI buffer to {grownMdiCapacity} commands");
_mdiCommandCapacity = grownMdiCapacity;
int grownBatchCapacity = grownMdiCapacity;
TrackedGlResource.AllocateBufferStorage(
_gl,
GLEnum.ShaderStorageBuffer,
_modernBatchBuffer,
(long)_modernBatchCapacity * sizeof(ModernBatchData),
(long)grownBatchCapacity * sizeof(ModernBatchData),
GLEnum.DynamicDraw,
$"growing EnvCell batch SSBO to {grownBatchCapacity} batches");
_modernBatchCapacity = grownBatchCapacity;
}
if (uniqueInstanceCount > _modernInstanceCapacity)
{
int grownInstanceCapacity = Math.Max(_modernInstanceCapacity * 2, uniqueInstanceCount);
TrackedGlResource.AllocateBufferStorage(
_gl,
GLEnum.ShaderStorageBuffer,
_modernInstanceBuffer,
(long)_modernInstanceCapacity * sizeof(Matrix4x4),
(long)grownInstanceCapacity * sizeof(Matrix4x4),
GLEnum.DynamicDraw,
$"growing EnvCell instance SSBO to {grownInstanceCapacity} instances");
_modernInstanceCapacity = grownInstanceCapacity;
}
// Phase U.3: keep the clip-slot buffer (binding=3) sized to the
// instance prefix so instanceClipSlot[BaseInstance + gl_InstanceID]
// is always in range. It owns an independent committed capacity so a
// failed allocation can never publish the instance buffer's growth as
// if both resources had succeeded.
if (uniqueInstanceCount > _clipSlotCapacity)
{
int grownClipCapacity = Math.Max(_clipSlotCapacity * 2, uniqueInstanceCount);
TrackedGlResource.AllocateBufferStorage(
_gl,
GLEnum.ShaderStorageBuffer,
_clipSlotBuffer,
(long)_clipSlotCapacity * sizeof(uint),
(long)grownClipCapacity * sizeof(uint),
GLEnum.DynamicDraw,
$"growing EnvCell clip-slot SSBO to {grownClipCapacity} instances");
_clipSlotCapacity = grownClipCapacity;
}
if (uniqueInstanceCount > _instLightSetCapacity)
{
int grownLightSetCapacity = Math.Max(_instLightSetCapacity * 2, uniqueInstanceCount);
TrackedGlResource.AllocateBufferStorage(
_gl,
GLEnum.ShaderStorageBuffer,
_instLightSetSsbo,
(long)_instLightSetCapacity
* AcDream.Core.Lighting.LightManager.MaxLightsPerObject
* sizeof(int),
(long)grownLightSetCapacity
* AcDream.Core.Lighting.LightManager.MaxLightsPerObject
* sizeof(int),
GLEnum.DynamicDraw,
$"growing EnvCell light-set SSBO to {grownLightSetCapacity} instances");
_instLightSetCapacity = grownLightSetCapacity;
}
}
// imperative state bracket. Every per-frame section is a ring slice
// allocated fresh in SubmitRhi, and blend plus depth-write are baked
// into the three shell pipelines rather than set here. The frame-
// started invariant this used to enforce via ActivateNextDynamicBufferSet
// still matters (it gates the light-frame-generation cache), so it is
// checked directly.
if (!_dynamicFrameStarted)
throw new InvalidOperationException("BeginFrame must be called before drawing EnvCells.");
// WB BaseObjectRenderManager.cs:761-762: grow scratch arrays.
if (_commands.Length < totalDraws)
@ -1684,189 +1319,7 @@ public sealed unsafe partial class EnvCellRenderer :
}
}
if (_gl is null)
{
SubmitRhi(allInstances, renderPass, totalDraws, uniqueInstanceCount);
return;
}
// WB BaseObjectRenderManager.cs:784-805 upload. Retain capacity and
// update the active prefix so portal frames cannot enqueue an unbounded
// chain of retired driver allocations.
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer);
fixed (DrawElementsIndirectCommand* ptr = _commands)
{
_gl.BufferSubData(GLEnum.DrawIndirectBuffer, 0,
(nuint)(totalDraws * sizeof(DrawElementsIndirectCommand)), ptr);
}
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer);
if (_gpuInstanceTransforms.Length < uniqueInstanceCount)
Array.Resize(ref _gpuInstanceTransforms, Math.Max(_gpuInstanceTransforms.Length * 2, uniqueInstanceCount));
for (int i = 0; i < uniqueInstanceCount; i++)
_gpuInstanceTransforms[i] = allInstances[i].Transform;
fixed (Matrix4x4* ptr = _gpuInstanceTransforms)
{
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
(nuint)(uniqueInstanceCount * sizeof(Matrix4x4)), ptr);
}
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernBatchBuffer);
fixed (ModernBatchData* ptr = _modernBatches)
{
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
(nuint)(totalDraws * sizeof(ModernBatchData)), ptr);
}
// Phase U.4: upload the per-instance clip-slot buffer (binding=3). When
// _cellIdToSlot is set (indoor routing), each cell shell instance is gated
// to its cell's CellClip slot via allInstances[i].CellId; cells absent from
// the map (shouldn't happen — the Render filter is the map's keys) and the
// U.3 path both map to slot 0 (no-clip). allInstances is laid out in the
// SAME order as the binding=0 transforms (_gpuInstanceTransforms below), so
// instanceClipSlot[i] tracks Instances[i] through the MDI BaseInstance.
if (_clipSlotData.Length < uniqueInstanceCount)
_clipSlotData = new uint[Math.Max(_clipSlotData.Length * 2, uniqueInstanceCount)];
// #176 stripe-hunt isolation (ACDREAM_CLIP_DEBUG=1): force every shell
// instance to slot 0 (no-clip) — retail draws cell shells WHOLE.
if (_cellIdToSlot is null
|| AcDream.Core.Rendering.RenderingDiagnostics.ClipDebugNoShellTrim)
{
Array.Clear(_clipSlotData, 0, uniqueInstanceCount);
}
else
{
for (int i = 0; i < uniqueInstanceCount; i++)
_clipSlotData[i] = _cellIdToSlot.TryGetValue(allInstances[i].CellId, out int slot)
? (uint)slot : 0u;
}
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _clipSlotBuffer);
fixed (uint* ptr = _clipSlotData)
{
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
(nuint)(uniqueInstanceCount * sizeof(uint)), ptr);
}
// A7 Fix D (D-2): per-instance 8-int light set, parallel to the transforms,
// keyed on the cell each shell instance belongs to (mirrors _clipSlotData).
int lightStride = AcDream.Core.Lighting.LightManager.MaxLightsPerObject;
if (_lightSetData.Length < uniqueInstanceCount * lightStride)
_lightSetData = new int[System.Math.Max(_lightSetData.Length * 2, uniqueInstanceCount * lightStride)];
for (int i = 0; i < uniqueInstanceCount; i++)
{
int[] cellSet = GetCellLightSet(allInstances[i].CellId);
System.Array.Copy(cellSet, 0, _lightSetData, i * lightStride, lightStride);
}
// #176 seam-draw probe: emitted HERE (not in Render) so the per-cell light
// sets read through the just-cleared cache against THIS frame's
// _pointSnapshot — the exact data the SSBO upload below carries.
if (renderPass == WbRenderPass.Opaque
&& AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled)
EmitSeamDrawProbe(drawCalls, allInstances, _seamProbeFilter);
// A7 Fix D (D-2): upload binding=4 (global lights) + binding=5 (per-instance set).
int lightCount = AcDream.Core.Lighting.GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
int glUploadCount = lightCount > 0 ? lightCount : 1;
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo);
if (glUploadCount > _globalLightsCapacity)
{
int grownGlobalLightCapacity = Math.Max(_globalLightsCapacity * 2, glUploadCount);
TrackedGlResource.AllocateBufferStorage(
_gl,
GLEnum.ShaderStorageBuffer,
_globalLightsSsbo,
(long)_globalLightsCapacity
* AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight
* sizeof(float),
(long)grownGlobalLightCapacity
* AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight
* sizeof(float),
GLEnum.DynamicDraw,
$"growing EnvCell global-light SSBO to {grownGlobalLightCapacity} lights");
_globalLightsCapacity = grownGlobalLightCapacity;
}
fixed (float* gp = _globalLightData)
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
(nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)), gp);
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo);
fixed (int* lp = _lightSetData)
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
(nuint)(uniqueInstanceCount * lightStride * sizeof(int)), lp);
PersistActiveDynamicBufferCapacities();
// WB BaseObjectRenderManager.cs:807-818: bind VAO + SSBOs + barrier.
// (globalVao validated at the top of the method — a return here would leak the
// pass state established above.)
if (_currentVao != globalVao)
{
_gl.BindVertexArray(globalVao);
_currentVao = globalVao;
}
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 0, _modernInstanceBuffer);
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 1, _modernBatchBuffer);
// Phase U.3: per-instance clip slots (binding=3) + shared clip regions
// (binding=2, via the GameWindow ClipFrame or our no-clip fallback).
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 3, _clipSlotBuffer);
BindClipRegionBinding2();
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 4, _globalLightsSsbo); // A7 Fix D (D-2)
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 5, _instLightSetSsbo); // A7 Fix D (D-2)
FlushAndBindTextureTable(); // Campaign V slice V2 (binding=9)
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer);
_gl.MemoryBarrier(MemoryBarrierMask.ShaderStorageBarrierBit | MemoryBarrierMask.CommandBarrierBit);
// WB BaseObjectRenderManager.cs:821-847: issue per-group multi-draw calls.
// The ranges retain ordered-cell boundaries, so transparent geometry
// stays far-to-near even though all command data was uploaded once.
for (int drawRangeIndex = 0; drawRangeIndex < _mdiDrawRanges.Count; drawRangeIndex++)
{
MdiDrawRange drawRange = _mdiDrawRanges[drawRangeIndex];
int groupIndex = drawRange.GroupIndex;
var cullMode = (CullMode)(groupIndex % 4);
// Phase A8 visual-gate evidence: cell meshes use CullMode.Landblock
// uniformly, but the room surfaces need to be visible from inside
// under acdream's current global winding state. Render cell polys
// double-sided while the architectural cause is isolated.
if (cullMode == CullMode.Landblock) cullMode = CullMode.None;
if (_currentCullMode != cullMode)
{
SetCullMode(cullMode);
}
bool isAdditive = groupIndex >= 4;
if (isAdditive)
{
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One);
shader!.SetInt("uRenderPass", (int)renderPass | 0x100);
}
else
{
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
shader!.SetInt("uRenderPass", (int)renderPass);
}
shader!.SetInt("uDrawIDOffset", drawRange.FirstCommand);
_gl.MultiDrawElementsIndirect(
PrimitiveType.Triangles,
DrawElementsType.UnsignedShort,
(void*)(drawRange.FirstCommand * sizeof(DrawElementsIndirectCommand)),
(uint)drawRange.CommandCount,
(uint)sizeof(DrawElementsIndirectCommand));
}
// Phase U.4: leave a clean opaque-default render state (mirrors WbDrawDispatcher's
// post-transparent restore) so a Transparent pass's Blend-on / DepthMask-off does
// not leak into particles or the next frame's draws.
_gl.Disable(EnableCap.Blend);
_gl.DepthMask(true);
// WB BaseObjectRenderManager.cs:845-847:
shader!.SetInt("uDrawIDOffset", 0);
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0);
SubmitRhi(allInstances, renderPass, totalDraws, uniqueInstanceCount);
}
internal static void AppendMdiDrawRange(
@ -1994,116 +1447,6 @@ public sealed unsafe partial class EnvCellRenderer :
System.Console.WriteLine($"[seam-blk] t={now} changed={(changed ? 1 : 0)}{sig}");
}
// ---------------------------------------------------------------------------
// SetCullMode
// Verbatim copy of WB BaseObjectRenderManager.cs:850-866.
// ---------------------------------------------------------------------------
private void SetCullMode(CullMode mode)
{
_currentCullMode = mode;
switch (mode)
{
case CullMode.None:
_gl!.Disable(EnableCap.CullFace);
break;
case CullMode.Clockwise:
_gl!.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Front);
break;
case CullMode.CounterClockwise:
case CullMode.Landblock:
_gl!.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
break;
}
}
// ---------------------------------------------------------------------------
// FlushAndBindTextureTable (Campaign V slice V2)
// ---------------------------------------------------------------------------
/// <summary>
/// Campaign V slice V4t: drains the device texture table's dirty runs and
/// (re)binds it at
/// <see cref="AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable"/>.
/// A genuinely new slot is rare — new dat surfaces/atlases, not every frame
/// — but the bind is unconditional, because GL's storage-buffer binding
/// points are global and another raw-GL renderer's binding 9 sits there
/// between two of these draws.
/// </summary>
private void FlushAndBindTextureTable()
{
AcDream.App.Rendering.Gpu.Gl.GlGpuDevice device = WorldTextureTable;
device.FlushTextureTable();
_gl!.BindBufferBase(
GLEnum.ShaderStorageBuffer,
AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable,
device.TextureTableGlName);
}
// ---------------------------------------------------------------------------
// BindClipRegionBinding2 (Phase U.3)
// ---------------------------------------------------------------------------
/// <summary>
/// Bind the per-cell clip-region SSBO to binding=2. Prefers the shared
/// <see cref="ClipFrame"/> buffer (<see cref="SetClipRegionSsbo"/>); otherwise
/// lazily creates + binds a one-slot no-clip fallback (count 0 = pass-all) so
/// the shader never reads an unbound SSBO.
/// </summary>
private void BindClipRegionBinding2()
{
if (_sharedClipRegionSsbo != 0)
{
_gl!.BindBufferBase(GLEnum.ShaderStorageBuffer,
AcDream.App.Rendering.ClipFrame.MeshClipSsboBinding, _sharedClipRegionSsbo);
return;
}
if (_fallbackClipRegionSsbo == 0)
{
uint fallback = TrackedGlResource.CreateBuffer(_gl!, "creating EnvCell fallback clip SSBO");
bool allocated = false;
try
{
TrackedGlResource.AllocateBufferStorage(
_gl!,
GLEnum.ShaderStorageBuffer,
fallback,
0,
AcDream.App.Rendering.ClipFrame.CellClipStrideBytes,
GLEnum.DynamicDraw,
"allocating EnvCell fallback clip SSBO");
allocated = true;
// One CellClip slot, all zeros: count 0 ⇒ shader passes every plane.
Span<byte> zero = stackalloc byte[AcDream.App.Rendering.ClipFrame.CellClipStrideBytes];
zero.Clear();
fixed (byte* p = zero)
{
_gl!.BufferSubData(
GLEnum.ShaderStorageBuffer,
0,
(nuint)zero.Length,
p);
}
GLHelpers.ThrowOnResourceError(_gl, "initializing EnvCell fallback clip SSBO");
_fallbackClipRegionSsbo = fallback;
}
catch
{
TrackedGlResource.DeleteBuffer(
_gl!,
fallback,
allocated ? AcDream.App.Rendering.ClipFrame.CellClipStrideBytes : 0,
"rolling back EnvCell fallback clip SSBO");
throw;
}
}
_gl!.BindBufferBase(GLEnum.ShaderStorageBuffer,
AcDream.App.Rendering.ClipFrame.MeshClipSsboBinding, _fallbackClipRegionSsbo);
}
// ---------------------------------------------------------------------------
// List pool (GetPooledList)
// Copied from WB ObjectRenderManagerBase (pattern).
@ -2154,67 +1497,12 @@ public sealed unsafe partial class EnvCellRenderer :
{
("prepare-scratch", _prepareScratch.Dispose),
};
// Campaign V slice V6j: the encoder arm owns no GL names — its
// pipelines route their physical free through the device's
// retirement queue — so the ledger below holds only the scratch.
if (_gl is null)
DisposeRhiResources();
// Campaign V slice V11: the raw-GL arm's dynamic buffer-set pool is
// deleted along with it. The RHI arm's pipelines route their
// physical free through the device's own retirement queue, so the
// ledger above holds only the scratch.
DisposeRhiResources();
for (int frame = 0; frame < _dynamicBufferSetsByFrame.Length; frame++)
{
List<DynamicBufferSet> frameSets = _dynamicBufferSetsByFrame[frame];
for (int index = 0; index < frameSets.Count; index++)
{
DynamicBufferSet set = frameSets[index];
AddTrackedBufferRelease(
releases,
set.MdiCommandBuffer,
(long)set.MdiCommandCapacity * sizeof(DrawElementsIndirectCommand),
$"dynamic-{frame}-{index}-mdi",
"deleting EnvCell MDI buffer");
AddTrackedBufferRelease(
releases,
set.ModernInstanceBuffer,
(long)set.ModernInstanceCapacity * sizeof(Matrix4x4),
$"dynamic-{frame}-{index}-instances",
"deleting EnvCell instance SSBO");
AddTrackedBufferRelease(
releases,
set.ModernBatchBuffer,
(long)set.ModernBatchCapacity * sizeof(ModernBatchData),
$"dynamic-{frame}-{index}-batches",
"deleting EnvCell batch SSBO");
AddTrackedBufferRelease(
releases,
set.ClipSlotBuffer,
(long)set.ClipSlotCapacity * sizeof(uint),
$"dynamic-{frame}-{index}-clip-slots",
"deleting EnvCell clip-slot SSBO");
AddTrackedBufferRelease(
releases,
set.GlobalLightsSsbo,
(long)set.GlobalLightsCapacity
* AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight
* sizeof(float),
$"dynamic-{frame}-{index}-global-lights",
"deleting EnvCell global-light SSBO");
AddTrackedBufferRelease(
releases,
set.InstanceLightSetSsbo,
(long)set.InstanceLightSetCapacity
* AcDream.Core.Lighting.LightManager.MaxLightsPerObject
* sizeof(int),
$"dynamic-{frame}-{index}-light-sets",
"deleting EnvCell light-set SSBO");
}
}
AddTrackedBufferRelease(
releases,
_fallbackClipRegionSsbo,
AcDream.App.Rendering.ClipFrame.CellClipStrideBytes,
"fallback-clip-region",
"deleting EnvCell fallback clip SSBO");
_disposeResources = new RetryableResourceReleaseLedger(releases);
}
@ -2225,17 +1513,7 @@ public sealed unsafe partial class EnvCellRenderer :
"One or more EnvCell renderer resources could not be released.");
}
foreach (List<DynamicBufferSet> frameSets in _dynamicBufferSetsByFrame)
frameSets.Clear();
_activeDynamicBufferSet = null;
_dynamicFrameStarted = false;
_mdiCommandBuffer = 0;
_modernInstanceBuffer = 0;
_modernBatchBuffer = 0;
_clipSlotBuffer = 0;
_globalLightsSsbo = 0;
_instLightSetSsbo = 0;
_fallbackClipRegionSsbo = 0;
_disposeResources = null;
IsDisposed = true;
@ -2250,22 +1528,4 @@ public sealed unsafe partial class EnvCellRenderer :
_disposing = false;
}
}
private void AddTrackedBufferRelease(
List<(string Name, Action Release)> releases,
uint buffer,
long capacityBytes,
string name,
string context)
{
if (buffer == 0)
return;
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl!,
buffer,
capacityBytes,
context);
releases.Add((name, release.Run));
}
}

View file

@ -1,240 +0,0 @@
using Microsoft.Extensions.Logging;
using Silk.NET.Core.Native;
using Silk.NET.OpenGL;
using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace AcDream.App.Rendering.Wb {
public static class GLHelpers {
public static OpenGLGraphicsDevice? Device { get; set; }
public static ILogger? Logger { get; set; }
public static void Init(OpenGLGraphicsDevice device, ILogger logger) {
Logger = logger;
Device = device;
}
/// <summary>
/// Always-on error boundary for resource transactions. Most render-path
/// checks remain Debug-only because <c>glGetError</c> is a synchronous
/// driver call; allocation/upload code must not publish CPU state after
/// OpenGL reported OOM, context loss, or a rejected transfer in Release.
/// Call exactly once before committing each transaction.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void ThrowOnResourceError(GL gl, string context) {
GLEnum error = gl.GetError();
if (error == GLEnum.NoError)
return;
var errors = new System.Text.StringBuilder();
do {
if (errors.Length != 0)
errors.Append(", ");
errors.Append(error).Append(" (").Append(GetErrorDetails(error)).Append(')');
error = gl.GetError();
} while (error != GLEnum.NoError);
string message = $"OpenGL resource transaction failed: {errors}. Context: {context}";
Logger?.LogError(message);
throw new InvalidOperationException(message);
}
#if DEBUG
private static bool _loggedVersion = false;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void CheckErrors(GL gl, bool logErrors = false, [CallerMemberName] string callerName = "",
[CallerFilePath] string callerFile = "", [CallerLineNumber] int callerLine = 0) {
var error = gl.GetError();
if (error != GLEnum.NoError) {
if (!_loggedVersion) {
_loggedVersion = true;
var version = gl.GetStringS(GLEnum.Version);
var vendor = gl.GetStringS(GLEnum.Vendor);
var renderer = gl.GetStringS(GLEnum.Renderer);
Logger?.LogInformation($"GL Version: {version}, Vendor: {vendor}, Renderer: {renderer}");
}
string errorDetails = GetErrorDetails(error);
string location = $"{System.IO.Path.GetFileName(callerFile)}::{callerName}:{callerLine}";
var program = (uint)gl.GetInteger(GLEnum.CurrentProgram);
var vao = gl.GetInteger(GLEnum.VertexArrayBinding);
var activeTex = gl.GetInteger(GLEnum.ActiveTexture);
var threadId = System.Threading.Thread.CurrentThread.ManagedThreadId;
string extraInfo = "";
if (program != 0) {
bool isProgram = gl.IsProgram(program);
gl.GetProgram(program, GLEnum.LinkStatus, out int linkStatus);
gl.GetProgram(program, GLEnum.DeleteStatus, out int deleteStatus);
gl.GetProgram(program, GLEnum.ValidateStatus, out int validateStatus);
extraInfo = $", IsProg: {isProgram}, Link: {linkStatus}, Del: {deleteStatus}, Valid: {validateStatus}";
}
string message = $"OpenGL Error: {error} ({errorDetails}) at {location}. Thread: {threadId}, Program: {program}{extraInfo}, VAO: {vao}, ActiveTex: {activeTex}";
Logger?.LogError(message);
throw new Exception(message);
}
}
#else
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void CheckErrors(GL gl, bool logErrors = false, string callerName = "",
string callerFile = "", int callerLine = 0) {
}
#endif
public static string GetErrorDetails(GLEnum error) {
return error switch {
GLEnum.InvalidEnum => "Invalid enum - An unacceptable value is specified for an enumerated argument",
GLEnum.InvalidValue => "Invalid value - A numeric argument is out of range",
GLEnum.InvalidOperation =>
"Invalid operation - The specified operation is not allowed in the current state",
GLEnum.StackOverflow => "Stack overflow - An operation would cause an internal stack to overflow",
GLEnum.StackUnderflow => "Stack underflow - An operation would cause an internal stack to underflow",
GLEnum.OutOfMemory => "Out of memory - There is not enough memory left to execute the command",
GLEnum.InvalidFramebufferOperation =>
"Invalid framebuffer operation - The framebuffer object is not complete",
GLEnum.ContextLost => "Context lost - The OpenGL context has been lost due to a graphics card reset",
_ => "Unknown error"
};
}
#if DEBUG
/// <summary>
/// Checks for OpenGL errors and provides context-specific information
/// </summary>
public static void CheckErrorsWithContext(GL gl, string context, [CallerMemberName] string callerName = "",
[CallerFilePath] string callerFile = "", [CallerLineNumber] int callerLine = 0) {
var error = gl.GetError();
if (error != GLEnum.NoError) {
string errorDetails = GetErrorDetails(error);
string location = $"{System.IO.Path.GetFileName(callerFile)}::{callerName}:{callerLine}";
string message = $"OpenGL Error: {error} ({errorDetails})\nContext: {context}\nLocation: {location}";
Logger?.LogError(message);
throw new Exception(message);
}
}
#else
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void CheckErrorsWithContext(GL gl, string context, string callerName = "",
string callerFile = "", int callerLine = 0) {
}
#endif
/// <summary>
/// Gets detailed information about the current texture state for debugging
/// </summary>
public static string GetTextureDebugInfo(GL gl, GLEnum target) {
var info = new System.Text.StringBuilder();
info.AppendLine($"Texture Debug Info for {target}:");
try {
gl.GetTextureLevelParameter((uint)gl.GetInteger(GetPName.TextureBinding2DArray), 0,
GetTextureParameter.TextureWidth, out int width);
gl.GetTextureLevelParameter((uint)gl.GetInteger(GetPName.TextureBinding2DArray), 0,
GetTextureParameter.TextureHeight, out int height);
gl.GetTextureLevelParameter((uint)gl.GetInteger(GetPName.TextureBinding2DArray), 0,
GetTextureParameter.TextureDepthExt, out int depth);
gl.GetTextureLevelParameter((uint)gl.GetInteger(GetPName.TextureBinding2DArray), 0,
GetTextureParameter.TextureInternalFormat, out int format);
info.AppendLine($" Dimensions: {width}x{height}x{depth}");
info.AppendLine($" Internal Format: {(InternalFormat)format}");
gl.GetTexParameter(target, GetTextureParameter.TextureMinFilter, out int minFilter);
gl.GetTexParameter(target, GetTextureParameter.TextureMagFilter, out int magFilter);
info.AppendLine($" Min Filter: {(TextureMinFilter)minFilter}");
info.AppendLine($" Mag Filter: {(TextureMagFilter)magFilter}");
// Get max mipmap level
gl.GetTexParameter(target, GetTextureParameter.TextureMaxLevelSgis, out int maxLevel);
info.AppendLine($" Max Level: {maxLevel}");
// Check completeness
int maxMipLevel = (int)Math.Floor(Math.Log2(Math.Max(width, height)));
info.AppendLine($" Calculated Max Mip Level: {maxMipLevel}");
}
catch (Exception ex) {
info.AppendLine($" Error getting texture info: {ex.Message}");
}
return info.ToString();
}
/// <summary>
/// Logs current OpenGL state for debugging
/// </summary>
public static void LogGLState(GL gl, string context = "") {
var state = new System.Text.StringBuilder();
state.AppendLine($"=== OpenGL State ({context}) ===");
try {
state.AppendLine(
$"Active Texture Unit: GL_TEXTURE{gl.GetInteger(GetPName.ActiveTexture) - (int)GLEnum.Texture0}");
state.AppendLine($"Bound 2D Array Texture: {gl.GetInteger(GetPName.TextureBinding2DArray)}");
state.AppendLine($"Current Program: {gl.GetInteger(GetPName.CurrentProgram)}");
gl.GetInteger(GetPName.MaxTextureSize, out int maxTexSize);
state.AppendLine($"Max Texture Size: {maxTexSize}");
gl.GetInteger(GetPName.Max3DTextureSize, out int max3DSize);
state.AppendLine($"Max 3D Texture Size: {max3DSize}");
gl.GetInteger(GetPName.MaxArrayTextureLayers, out int maxLayers);
state.AppendLine($"Max Array Texture Layers: {maxLayers}");
}
catch (Exception ex) {
state.AppendLine($"Error getting GL state: {ex.Message}");
}
state.AppendLine("======================");
Logger?.LogInformation(state.ToString());
}
/// <summary>
/// Explicit defaults to prevent Avalonia state leakage into our custom rendering pipeline.
/// Call this at the start of complex render cycles immediately inside a GLStateScope.
/// </summary>
public static void SetupDefaultRenderState(GL gl) {
gl.BindSampler(0, 0);
gl.BindSampler(1, 0);
gl.BindSampler(2, 0);
gl.ActiveTexture(TextureUnit.Texture1);
gl.BindTexture(TextureTarget.Texture2D, 0);
gl.ActiveTexture(TextureUnit.Texture2);
gl.BindTexture(TextureTarget.Texture2D, 0);
gl.ActiveTexture(TextureUnit.Texture0); // End on Texture0
gl.BindTexture(TextureTarget.Texture2D, 0);
gl.BindVertexArray(0);
gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, 0);
gl.UseProgram(0);
gl.PixelStore(PixelStoreParameter.UnpackAlignment, 1);
gl.PixelStore(PixelStoreParameter.UnpackRowLength, 0);
gl.PixelStore(PixelStoreParameter.UnpackSkipRows, 0);
gl.PixelStore(PixelStoreParameter.UnpackSkipPixels, 0);
gl.Disable(EnableCap.StencilTest);
gl.BlendColor(0, 0, 0, 0);
gl.PolygonMode(GLEnum.FrontAndBack, PolygonMode.Fill);
// Disable Avalonia/Skia specific states
gl.Disable(EnableCap.SampleAlphaToCoverage);
gl.Disable(EnableCap.SampleAlphaToOne);
gl.Disable(EnableCap.Multisample);
gl.Disable((EnableCap)GLEnum.PrimitiveRestart);
gl.LineWidth(1.0f);
gl.PolygonOffset(0f, 0f);
gl.Disable(EnableCap.PolygonOffsetFill);
gl.Disable((EnableCap)GLEnum.ProgramPointSize);
}
}
}

View file

@ -1,258 +0,0 @@
using Chorizite.Core.Render;
using AcDream.App.Rendering;
using Microsoft.Extensions.Logging;
using Silk.NET.OpenGL;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
using System.Xml.Linq;
namespace AcDream.App.Rendering.Wb {
public unsafe class GLSLShader : BaseShader, IDisposable {
private OpenGLGraphicsDevice _device;
private Dictionary<string, int> _uniformLocations = [];
private Dictionary<int, object> _uniformValues = [];
private readonly object _lock = new();
private GL GL => _device.GL;
public uint Program { get; protected set; }
public bool HasUniform(string name) {
lock (_lock) {
return GetUniformLocation(Program, name) != -1;
}
}
public GLSLShader(OpenGLGraphicsDevice device, string name, string vertSource, string fragSource, ILogger log) : base(name, vertSource, fragSource, log) {
_device = device;
Load(vertSource, fragSource);
}
public GLSLShader(OpenGLGraphicsDevice device, string name, string shaderDirectory, ILogger log) : base(name, shaderDirectory, log) {
_device = device;
Load();
}
public override void Dispose() {
Unload();
base.Dispose();
}
private int GetUniformLocation(uint program, string name) {
lock (_lock) {
if (!_uniformLocations.ContainsKey(name)) {
_uniformLocations.Add(name, GL.GetUniformLocation(program, name));
}
return _uniformLocations[name];
}
}
public override void SetUniform(string location, Matrix4x4 m) {
lock (_lock) {
int loc = GetUniformLocation(Program, location);
if (loc == -1) return;
if (_uniformValues.TryGetValue(loc, out var val) && val is Matrix4x4 mCached && mCached == m) {
return;
}
_uniformValues[loc] = m;
GL.UniformMatrix4(loc, 1, false, (float*)&m);
}
}
public override void SetUniform(string location, int v) {
lock (_lock) {
int loc = GetUniformLocation((uint)Program, location);
if (loc == -1) return;
if (_uniformValues.TryGetValue(loc, out var val) && val is int vCached && vCached == v) {
return;
}
_uniformValues[loc] = v;
GL.Uniform1(loc, v);
}
}
public override void SetUniform(string location, Vector2 vec) {
lock (_lock) {
int loc = GetUniformLocation((uint)Program, location);
if (loc == -1) return;
if (_uniformValues.TryGetValue(loc, out var val) && val is Vector2 vCached && vCached == vec) {
return;
}
_uniformValues[loc] = vec;
GL.Uniform2(loc, vec);
}
}
public override void SetUniform(string location, Vector3 vec) {
lock (_lock) {
int loc = GetUniformLocation((uint)Program, location);
if (loc == -1) return;
if (_uniformValues.TryGetValue(loc, out var val) && val is Vector3 vCached && vCached == vec) {
return;
}
_uniformValues[loc] = vec;
GL.Uniform3(loc, vec);
}
}
public override void SetUniform(string location, Vector3[] vecs) {
lock (_lock) {
int loc = GetUniformLocation((uint)Program, location);
if (loc == -1) return;
fixed (float* v = &vecs[0].X) {
GL.Uniform3(loc, (uint)vecs.Length, v);
}
}
}
public override void SetUniform(string location, Vector4 vec) {
lock (_lock) {
int loc = GetUniformLocation((uint)Program, location);
if (loc == -1) return;
if (_uniformValues.TryGetValue(loc, out var val) && val is Vector4 vCached && vCached == vec) {
return;
}
_uniformValues[loc] = vec;
GL.Uniform4(loc, vec);
}
}
public override void SetUniform(string location, float v) {
lock (_lock) {
int loc = GetUniformLocation((uint)Program, location);
if (loc == -1) return;
if (_uniformValues.TryGetValue(loc, out var val) && val is float vCached && vCached == v) {
return;
}
_uniformValues[loc] = v;
GL.Uniform1(loc, v);
}
}
public override void SetUniform(string location, float[] vs) {
lock (_lock) {
fixed (float* v = &vs[0]) {
GL.Uniform1(GetUniformLocation((uint)Program, location), (uint)vs.Length, v);
}
}
}
public override void Load(string vertShaderSource, string fragShaderSource) {
if (string.IsNullOrWhiteSpace(vertShaderSource) || string.IsNullOrWhiteSpace(fragShaderSource)) {
_log.LogError($"Shader {Name} has no source code!");
throw new InvalidOperationException($"Shader {Name} has no source code.");
}
if (_device.HasOpenGL43 && _device.HasBindless) {
string replacement = "#version 430 core\n#extension GL_ARB_bindless_texture : require";
vertShaderSource = vertShaderSource.Replace("#version 330 core", replacement);
fragShaderSource = fragShaderSource.Replace("#version 330 core", replacement);
}
var resources = new ResourceCleanupGroup();
uint prog = 0;
bool accountingPublished = false;
try {
prog = ShaderProgramConstruction.Build(
new GlShaderProgramBuildApi(GL),
vertShaderSource,
fragShaderSource);
uint ownedProgram = prog;
var unpublishedProgramRelease = new RetryableGpuResourceRelease(
() => GlResourceCommand.DeleteProgram(
GL,
ownedProgram,
$"delete unpublished WB shader program {ownedProgram}"),
() => {
if (accountingPublished)
{
GpuMemoryTracker.TrackResourceDeallocation(
GpuResourceType.Shader);
}
});
resources.Add("WB shader program", unpublishedProgramRelease.Run);
// Bind SceneData uniform block to point 0 if it exists.
GlResourceCommand.Execute(GL, $"configure shader {Name} SceneData binding", () => {
uint sceneDataIndex = GL.GetUniformBlockIndex(prog, "SceneData");
if (sceneDataIndex != uint.MaxValue)
GL.UniformBlockBinding(prog, sceneDataIndex, 0);
});
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Shader);
accountingPublished = true;
resources.TransferAll();
} catch (Exception constructionFailure) {
_log.LogError(constructionFailure, "Failed to construct shader {ShaderName}", Name);
resources.RollbackConstructionAndThrow(
$"Shader {Name} construction failed and its GL program did not cleanly roll back.",
constructionFailure);
}
_log.LogTrace($"{(Program != 0 ? "Reloaded" : "Loaded")} shader: {Name}");
if (Program != 0) {
Unload();
}
_uniformLocations.Clear();
_uniformValues.Clear();
Program = prog;
ProgramId = prog;
NeedsLoad = false;
GLHelpers.CheckErrors(GL);
}
public override void Bind() {
lock (_lock) {
SetActive();
if (Program != 0) {
GL.UseProgram((uint)Program);
}
}
}
public override void Unbind() {
lock (_lock) {
GL.UseProgram(0);
GLHelpers.CheckErrors(GL);
}
}
protected override void Unload() {
lock (_lock) {
if (Program != 0) {
var prog = Program;
Program = 0;
ProgramId = 0;
_device.QueueGLAction(gl => {
gl.DeleteProgram(prog);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Shader);
});
}
}
}
}
}

View file

@ -1,230 +0,0 @@
using Silk.NET.OpenGL;
using System;
namespace AcDream.App.Rendering.Wb {
/// <summary>
/// A RAII scope for saving and restoring OpenGL state.
/// </summary>
public unsafe struct GLStateScope : IDisposable {
private readonly GL _gl;
private fixed int _viewport[4];
private bool _scissorTest;
private fixed int _scissorBox[4];
private bool _depthTest;
private int _depthFunc;
private bool _depthMask;
private bool _cullFace;
private int _cullFaceMode;
private int _frontFace;
private bool _blend;
private int _blendSrc;
private int _blendDst;
private int _blendEquation;
// Extended state
private int _blendSrcAlpha;
private int _blendDstAlpha;
private int _blendEquationAlpha;
private fixed byte _colorMask[4];
private fixed float _clearColor[4];
private float _clearDepth;
private int _currentProgram;
private int _vertexArrayBinding;
private int _arrayBufferBinding;
private int _elementArrayBufferBinding;
private int _activeTexture;
private int _textureBinding2D;
private bool _stencilTest;
private int _stencilFunc;
private int _stencilRef;
private int _stencilValueMask;
private int _stencilFail;
private int _stencilPassDepthFail;
private int _stencilPassDepthPass;
private int _stencilWritemask;
private int _unpackAlignment;
private int _packAlignment;
private int _drawFramebufferBinding;
// Skia / Avalonia extra state protections
private fixed float _blendColor[4];
private int _polygonMode;
private bool _sampleAlphaToCoverage;
private bool _multisample;
private bool _primitiveRestart;
private int _readFramebufferBinding;
private int _uniformBufferBinding0;
private float _lineWidth;
private bool _programPointSize;
private int _samplerBinding0;
private int _samplerBinding1;
private int _samplerBinding2;
private int _unpackRowLength;
private int _unpackSkipRows;
private int _unpackSkipPixels;
private bool _sampleAlphaToOne;
private bool _isDisposed;
/// <summary>
/// Captures the current OpenGL state.
/// </summary>
/// <param name="gl"></param>
public GLStateScope(GL gl) {
_gl = gl;
_isDisposed = false;
fixed (int* v = _viewport) _gl.GetInteger(GetPName.Viewport, v);
_scissorTest = _gl.IsEnabled(EnableCap.ScissorTest);
fixed (int* s = _scissorBox) _gl.GetInteger(GetPName.ScissorBox, s);
_depthTest = _gl.IsEnabled(EnableCap.DepthTest);
_gl.GetInteger(GetPName.DepthFunc, out _depthFunc);
byte depthMask = 0;
_gl.GetBoolean((GetPName)GLEnum.DepthWritemask, (bool*)&depthMask);
_depthMask = depthMask != 0;
_cullFace = _gl.IsEnabled(EnableCap.CullFace);
_gl.GetInteger(GetPName.CullFaceMode, out _cullFaceMode);
_gl.GetInteger(GetPName.FrontFace, out _frontFace);
_blend = _gl.IsEnabled(EnableCap.Blend);
_gl.GetInteger(GetPName.BlendSrcRgb, out _blendSrc);
_gl.GetInteger(GetPName.BlendDstRgb, out _blendDst);
_gl.GetInteger(GetPName.BlendSrcAlpha, out _blendSrcAlpha);
_gl.GetInteger(GetPName.BlendDstAlpha, out _blendDstAlpha);
_gl.GetInteger(GetPName.BlendEquationRgb, out _blendEquation);
_gl.GetInteger(GetPName.BlendEquationAlpha, out _blendEquationAlpha);
fixed (byte* c = _colorMask) _gl.GetBoolean((GetPName)GLEnum.ColorWritemask, (bool*)c);
fixed (float* cc = _clearColor) _gl.GetFloat(GetPName.ColorClearValue, cc);
_gl.GetFloat(GetPName.DepthClearValue, out _clearDepth);
_gl.GetInteger(GetPName.CurrentProgram, out _currentProgram);
_gl.GetInteger(GetPName.VertexArrayBinding, out _vertexArrayBinding);
_gl.GetInteger(GetPName.ArrayBufferBinding, out _arrayBufferBinding);
_gl.GetInteger(GetPName.ElementArrayBufferBinding, out _elementArrayBufferBinding);
_gl.GetInteger(GetPName.ActiveTexture, out _activeTexture);
_gl.GetInteger(GetPName.TextureBinding2D, out _textureBinding2D);
_stencilTest = _gl.IsEnabled(EnableCap.StencilTest);
_gl.GetInteger(GetPName.StencilFunc, out _stencilFunc);
_gl.GetInteger(GetPName.StencilRef, out _stencilRef);
_gl.GetInteger(GetPName.StencilValueMask, out _stencilValueMask);
_gl.GetInteger(GetPName.StencilFail, out _stencilFail);
_gl.GetInteger(GetPName.StencilPassDepthFail, out _stencilPassDepthFail);
_gl.GetInteger(GetPName.StencilPassDepthPass, out _stencilPassDepthPass);
_gl.GetInteger(GetPName.StencilWritemask, out _stencilWritemask);
_gl.GetInteger(GetPName.UnpackAlignment, out _unpackAlignment);
_gl.GetInteger(GetPName.PackAlignment, out _packAlignment);
_gl.GetInteger(GetPName.DrawFramebufferBinding, out _drawFramebufferBinding);
fixed (float* bc = _blendColor) _gl.GetFloat(GetPName.BlendColor, bc);
_gl.GetInteger(GetPName.PolygonMode, out _polygonMode);
_sampleAlphaToCoverage = _gl.IsEnabled(EnableCap.SampleAlphaToCoverage);
_multisample = _gl.IsEnabled(EnableCap.Multisample);
_primitiveRestart = _gl.IsEnabled((EnableCap)GLEnum.PrimitiveRestart);
_gl.GetInteger(GetPName.ReadFramebufferBinding, out _readFramebufferBinding);
_gl.GetInteger(GetPName.UniformBufferBinding, out _uniformBufferBinding0);
_gl.GetFloat(GetPName.LineWidth, out _lineWidth);
_programPointSize = _gl.IsEnabled((EnableCap)GLEnum.ProgramPointSize);
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.GetInteger((GetPName)GLEnum.SamplerBinding, out _samplerBinding0);
_gl.ActiveTexture(TextureUnit.Texture1);
_gl.GetInteger((GetPName)GLEnum.SamplerBinding, out _samplerBinding1);
_gl.ActiveTexture(TextureUnit.Texture2);
_gl.GetInteger((GetPName)GLEnum.SamplerBinding, out _samplerBinding2);
_gl.ActiveTexture((TextureUnit)_activeTexture);
_gl.GetInteger((GetPName)GLEnum.UnpackRowLength, out _unpackRowLength);
_gl.GetInteger((GetPName)GLEnum.UnpackSkipRows, out _unpackSkipRows);
_gl.GetInteger((GetPName)GLEnum.UnpackSkipPixels, out _unpackSkipPixels);
_sampleAlphaToOne = _gl.IsEnabled(EnableCap.SampleAlphaToOne);
}
/// <summary>
/// Restores only the scissor state from the scope.
/// </summary>
public void RestoreScissor() {
if (_scissorTest) _gl.Enable(EnableCap.ScissorTest);
else _gl.Disable(EnableCap.ScissorTest);
_gl.Scissor(_scissorBox[0], _scissorBox[1], (uint)_scissorBox[2], (uint)_scissorBox[3]);
}
/// <summary>
/// Restores the captured OpenGL state.
/// </summary>
public void Dispose() {
if (_isDisposed) return;
// Restoring state
if (_currentProgram != 0) _gl.UseProgram((uint)_currentProgram); else _gl.UseProgram(0);
_gl.BindVertexArray((uint)_vertexArrayBinding);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, (uint)_arrayBufferBinding);
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, (uint)_elementArrayBufferBinding);
_gl.BindBuffer(GLEnum.UniformBuffer, (uint)_uniformBufferBinding0);
_gl.ActiveTexture((TextureUnit)_activeTexture);
_gl.BindTexture(TextureTarget.Texture2D, (uint)_textureBinding2D);
if (_stencilTest) _gl.Enable(EnableCap.StencilTest); else _gl.Disable(EnableCap.StencilTest);
_gl.StencilFunc((StencilFunction)_stencilFunc, _stencilRef, (uint)_stencilValueMask);
_gl.StencilOp((StencilOp)_stencilFail, (StencilOp)_stencilPassDepthFail, (StencilOp)_stencilPassDepthPass);
_gl.StencilMask((uint)_stencilWritemask);
_gl.PixelStore(PixelStoreParameter.UnpackAlignment, _unpackAlignment);
_gl.PixelStore(PixelStoreParameter.PackAlignment, _packAlignment);
_gl.PixelStore(PixelStoreParameter.UnpackRowLength, _unpackRowLength);
_gl.PixelStore(PixelStoreParameter.UnpackSkipRows, _unpackSkipRows);
_gl.PixelStore(PixelStoreParameter.UnpackSkipPixels, _unpackSkipPixels);
_gl.ClearColor(_clearColor[0], _clearColor[1], _clearColor[2], _clearColor[3]);
_gl.ClearDepth(_clearDepth);
_gl.Viewport(_viewport[0], _viewport[1], (uint)_viewport[2], (uint)_viewport[3]);
RestoreScissor();
if (_depthTest) _gl.Enable(EnableCap.DepthTest); else _gl.Disable(EnableCap.DepthTest);
_gl.DepthFunc((DepthFunction)_depthFunc);
_gl.DepthMask(_depthMask);
if (_cullFace) _gl.Enable(EnableCap.CullFace); else _gl.Disable(EnableCap.CullFace);
_gl.CullFace((TriangleFace)_cullFaceMode);
_gl.FrontFace((FrontFaceDirection)_frontFace);
if (_blend) _gl.Enable(EnableCap.Blend); else _gl.Disable(EnableCap.Blend);
_gl.BlendFuncSeparate((BlendingFactor)_blendSrc, (BlendingFactor)_blendDst, (BlendingFactor)_blendSrcAlpha, (BlendingFactor)_blendDstAlpha);
_gl.BlendEquationSeparate((BlendEquationModeEXT)_blendEquation, (BlendEquationModeEXT)_blendEquationAlpha);
_gl.BlendColor(_blendColor[0], _blendColor[1], _blendColor[2], _blendColor[3]);
_gl.ColorMask(_colorMask[0] != 0, _colorMask[1] != 0, _colorMask[2] != 0, _colorMask[3] != 0);
_gl.PolygonMode(GLEnum.FrontAndBack, (PolygonMode)_polygonMode);
if (_sampleAlphaToCoverage) _gl.Enable(EnableCap.SampleAlphaToCoverage); else _gl.Disable(EnableCap.SampleAlphaToCoverage);
if (_sampleAlphaToOne) _gl.Enable(EnableCap.SampleAlphaToOne); else _gl.Disable(EnableCap.SampleAlphaToOne);
if (_multisample) _gl.Enable(EnableCap.Multisample); else _gl.Disable(EnableCap.Multisample);
if (_primitiveRestart) _gl.Enable((EnableCap)GLEnum.PrimitiveRestart); else _gl.Disable((EnableCap)GLEnum.PrimitiveRestart);
if (_programPointSize) _gl.Enable((EnableCap)GLEnum.ProgramPointSize); else _gl.Disable((EnableCap)GLEnum.ProgramPointSize);
_gl.LineWidth(_lineWidth);
_gl.BindSampler(0, (uint)_samplerBinding0);
_gl.BindSampler(1, (uint)_samplerBinding1);
_gl.BindSampler(2, (uint)_samplerBinding2);
_gl.BindFramebuffer(FramebufferTarget.DrawFramebuffer, (uint)_drawFramebufferBinding);
_gl.BindFramebuffer(FramebufferTarget.ReadFramebuffer, (uint)_readFramebufferBinding);
_isDisposed = true;
}
}
}

View file

@ -1,10 +1,8 @@
using System.Runtime.InteropServices;
using AcDream.Content;
using Chorizite.Core.Render.Enums;
using Silk.NET.OpenGL;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Gl;
namespace AcDream.App.Rendering.Wb;
@ -53,15 +51,6 @@ internal sealed class GlobalMeshMigrationAbortTicket
public void Advance() => _release.Run();
}
internal static class GlobalMeshVaoAccounting
{
public static void TrackAllocation() =>
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.VAO);
public static void TrackDeallocation() =>
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.VAO);
}
internal enum GlobalMeshCapacityResult
{
Ready,
@ -82,19 +71,15 @@ internal enum GlobalMeshCapacityResult
/// backend implements with <c>vkCmdCopyBuffer</c>. The reclaimable-range
/// allocator, growth quanta, budgeted incremental migration, retirement-ledger
/// gating and the dual-generation physical ceiling are unchanged; only the
/// resource handle type moved. The vertex array object stays raw GL because a
/// VAO has no RHI equivalent (Vulkan bakes vertex input into the pipeline) and
/// <c>WbDrawDispatcher</c>, <c>EnvCellRenderer</c> and <c>ParticleRenderer</c>
/// still bind <see cref="VAO"/>/<see cref="VBO"/>/<see cref="IBO"/> directly
/// on the GL arm.
/// resource handle type moved.
///
/// <para>Campaign V slice V6i-3 made the GL context optional. A backend that has
/// none builds no vertex array and publishes no raw names — <see cref="VAO"/>,
/// <see cref="VBO"/> and <see cref="IBO"/> are 0 there — and its consumers bind
/// <para>Campaign V slice V6i-3 made the GL context (and its vertex array
/// object, which has no RHI equivalent — Vulkan bakes vertex input into the
/// pipeline) optional; Campaign V slice V11 deleted the GL arm entirely, so
/// this arena now only ever builds the two backing stores. Its consumers bind
/// <see cref="VertexStore"/> and <see cref="IndexStore"/> through the pass
/// encoder instead, which is the same 32-byte position/normal/texcoord layout
/// expressed as pipeline vertex input. Everything above the handle — the
/// allocator, the migration, the ledger — is one body on both arms.</para>
/// encoder, which is the same 32-byte position/normal/texcoord layout
/// expressed as pipeline vertex input.</para>
/// </summary>
public sealed class GlobalMeshBuffer : IDisposable
{
@ -113,10 +98,6 @@ public sealed class GlobalMeshBuffer : IDisposable
internal const int MaximumIndexCapacity =
(int)(MaximumIndexBufferBytes / sizeof(ushort));
// Retained only for the vertex array object and its attribute layout, which
// the RHI has no verb for, and null on a backend with no such object. It is
// retired with the raw-GL dispatcher.
private readonly GL? _gl;
private readonly IGpuDevice _device;
private readonly GpuRetirementLedger _retirementLedger;
private readonly GpuRetiredRangeAllocator _vertices;
@ -134,20 +115,6 @@ public sealed class GlobalMeshBuffer : IDisposable
store ?? throw new InvalidOperationException(
"The global mesh arena has no live backing store.");
/// <summary>
/// Campaign V slice V4b transitional bridge. The arena owns its stores as
/// <see cref="IGpuBuffer"/>, but its consumers — the vertex array object here,
/// and <c>WbDrawDispatcher</c>/<c>EnvCellRenderer</c>/<c>ParticleRenderer</c>
/// through <see cref="VBO"/>/<see cref="IBO"/> — are still raw GL until slice
/// V4c. This is the only place that reaches through the interface, and it
/// disappears with those consumers.
/// </summary>
private static GlGpuBuffer RequireGlBuffer(IGpuBuffer buffer) =>
buffer as GlGpuBuffer
?? throw new NotSupportedException(
"The global mesh arena requires a GL-backed buffer while its draw paths "
+ "still bind raw GL names (Campaign V slice V4c retires that requirement).");
private enum BufferKind
{
Vertices,
@ -167,35 +134,16 @@ public sealed class GlobalMeshBuffer : IDisposable
public long CopiedBytes { get; set; }
}
public uint VAO { get; private set; }
/// <summary>
/// The vertex store's raw GL name, or 0 on a backend with no GL context.
/// Transitional: the GL draw paths still bind the arena themselves, so the
/// arena keeps publishing the backend name of the buffer it now owns as an
/// <see cref="IGpuBuffer"/>.
/// </summary>
public uint VBO =>
_gl is null || _vertexBuffer is null ? 0u : RequireGlBuffer(_vertexBuffer).GlName;
/// <summary>The index store's raw GL name. See <see cref="VBO"/>.</summary>
public uint IBO =>
_gl is null || _indexBuffer is null ? 0u : RequireGlBuffer(_indexBuffer).GlName;
/// <summary>
/// The vertex store as the contract's own handle. This is what a pass
/// encoder binds, and it is live on both arms — <see cref="VAO"/> is the
/// GL-only expression of the same thing.
/// encoder binds.
/// </summary>
internal IGpuBuffer? VertexStore => _vertexBuffer;
/// <summary>The index store as the contract's own handle. See <see cref="VertexStore"/>.</summary>
internal IGpuBuffer? IndexStore => _indexBuffer;
/// <summary>
/// True once both backing stores exist. The backend-neutral form of the
/// <c>VAO != 0</c> readiness test the raw-GL draw paths make.
/// </summary>
/// <summary>True once both backing stores exist.</summary>
internal bool HasStores => _vertexBuffer is not null && _indexBuffer is not null;
internal long UploadCount { get; private set; }
internal long UploadedBytes { get; private set; }
@ -268,9 +216,8 @@ public sealed class GlobalMeshBuffer : IDisposable
newBuffers);
}
internal GlobalMeshBuffer(GL? gl, IGpuDevice device, IGpuResourceRetirementQueue retirement)
internal GlobalMeshBuffer(IGpuDevice device, IGpuResourceRetirementQueue retirement)
{
_gl = gl;
_device = device ?? throw new ArgumentNullException(nameof(device));
ArgumentNullException.ThrowIfNull(retirement);
_retirementLedger = new GpuRetirementLedger(retirement);
@ -295,47 +242,20 @@ public sealed class GlobalMeshBuffer : IDisposable
| GpuBufferUsage.TransferDestination,
GpuMemoryResidency.DeviceLocal);
private unsafe void InitBuffers()
private void InitBuffers()
{
uint vao = 0;
IGpuBuffer? vbo = null;
IGpuBuffer? ibo = null;
long vertexBytes = (long)_vertices.Capacity * VertexPositionNormalTexture.Size;
long indexBytes = (long)_indices.Capacity * sizeof(ushort);
bool vaoTracked = false;
bool vertexTracked = false;
bool indexTracked = false;
try
{
// The vertex array is the one object here with no RHI equivalent —
// Vulkan bakes vertex input into the pipeline — so a backend with no
// GL context builds the two stores and nothing else.
if (_gl is { } gl)
{
gl.GenVertexArrays(1, out vao);
if (vao == 0)
throw new InvalidOperationException("OpenGL did not create the global mesh-buffer objects.");
}
vbo = _device.CreateBuffer(DescribeStore(BufferKind.Vertices, vertexBytes, _storeGeneration));
ibo = _device.CreateBuffer(DescribeStore(BufferKind.Indices, indexBytes, _storeGeneration));
if (_gl is { } glBind)
{
glBind.BindVertexArray(vao);
glBind.BindBuffer(GLEnum.ArrayBuffer, RequireGlBuffer(vbo).GlName);
ConfigureVertexAttributes(glBind);
glBind.BindBuffer(GLEnum.ElementArrayBuffer, RequireGlBuffer(ibo).GlName);
GLHelpers.ThrowOnResourceError(
glBind,
$"creating global mesh buffers ({vertexBytes} vertex bytes, {indexBytes} index bytes)");
GlobalMeshVaoAccounting.TrackAllocation();
vaoTracked = true;
}
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer);
GpuMemoryTracker.TrackAllocation(vertexBytes, GpuResourceType.Buffer);
vertexTracked = true;
@ -343,25 +263,15 @@ public sealed class GlobalMeshBuffer : IDisposable
GpuMemoryTracker.TrackAllocation(indexBytes, GpuResourceType.Buffer);
indexTracked = true;
VAO = vao;
_vertexBuffer = vbo;
_indexBuffer = ibo;
}
catch
{
// Construction rollback: nothing was ever submitted, so the physical
// stores are released on the spot rather than deferred. Pattern-matched
// rather than RequireGlBuffer'd so a non-GL store could never raise a
// cast failure that masks the original construction exception.
if (ibo is GlGpuBuffer stagedIndexStore)
stagedIndexStore.DeleteRetired("rolling back the global index arena buffer");
else
ibo?.Dispose();
if (vbo is GlGpuBuffer stagedVertexStore)
stagedVertexStore.DeleteRetired("rolling back the global vertex arena buffer");
else
vbo?.Dispose();
if (vao != 0) _gl!.DeleteVertexArray(vao);
// stores are released on the spot rather than deferred.
ibo?.Dispose();
vbo?.Dispose();
if (indexTracked)
{
GpuMemoryTracker.TrackDeallocation(indexBytes, GpuResourceType.Buffer);
@ -372,25 +282,8 @@ public sealed class GlobalMeshBuffer : IDisposable
GpuMemoryTracker.TrackDeallocation(vertexBytes, GpuResourceType.Buffer);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer);
}
if (vaoTracked)
GlobalMeshVaoAccounting.TrackDeallocation();
throw;
}
finally
{
_gl?.BindVertexArray(0);
}
}
private static unsafe void ConfigureVertexAttributes(GL gl)
{
int stride = VertexPositionNormalTexture.Size;
gl.EnableVertexAttribArray(0);
gl.VertexAttribPointer(0, 3, GLEnum.Float, false, (uint)stride, (void*)0);
gl.EnableVertexAttribArray(1);
gl.VertexAttribPointer(1, 3, GLEnum.Float, false, (uint)stride, (void*)(3 * sizeof(float)));
gl.EnableVertexAttribArray(2);
gl.VertexAttribPointer(2, 2, GLEnum.Float, false, (uint)stride, (void*)(6 * sizeof(float)));
}
internal GlobalMeshAllocation UploadMesh(
@ -673,9 +566,9 @@ public sealed class GlobalMeshBuffer : IDisposable
/// <summary>
/// Copies at most <paramref name="maximumCopyBytes"/> of the immutable
/// live prefix into the staged backing store. The active VAO continues to
/// reference the old store until the final chunk succeeds, then one atomic
/// VAO rebind publishes the destination.
/// live prefix into the staged backing store. Draws continue to reference
/// the old store until the final chunk succeeds, then one atomic field swap
/// (<see cref="CommitMigration"/>) publishes the destination.
/// </summary>
internal GlobalMeshMaintenanceStep AdvanceMigration(long maximumCopyBytes)
{
@ -819,48 +712,9 @@ public sealed class GlobalMeshBuffer : IDisposable
private void CommitMigration(BufferMigration migration)
{
// The atomic publication step is a VAO rebind on GL and nothing at all
// on a backend whose vertex source is a per-draw encoder bind: the field
// swap below IS the publication there, and the next pass reads the new
// store. The rollback arm exists for the same reason it did — a failed
// rebind must leave the vertex array pointing at the live store.
if (_gl is { } gl)
{
try
{
gl.BindVertexArray(VAO);
if (migration.Kind == BufferKind.Vertices)
{
gl.BindBuffer(GLEnum.ArrayBuffer, RequireGlBuffer(migration.NewBuffer).GlName);
ConfigureVertexAttributes(gl);
}
else
{
gl.BindBuffer(GLEnum.ElementArrayBuffer, RequireGlBuffer(migration.NewBuffer).GlName);
}
GLHelpers.ThrowOnResourceError(gl, $"publishing staged {migration.Kind} arena buffer");
}
catch
{
gl.BindVertexArray(VAO);
if (migration.Kind == BufferKind.Vertices)
{
gl.BindBuffer(GLEnum.ArrayBuffer, RequireGlBuffer(migration.OldBuffer).GlName);
ConfigureVertexAttributes(gl);
}
else
{
gl.BindBuffer(GLEnum.ElementArrayBuffer, RequireGlBuffer(migration.OldBuffer).GlName);
}
gl.BindVertexArray(0);
throw;
}
finally
{
gl.BindVertexArray(0);
}
}
// The atomic publication step is nothing at all here: the vertex source
// is a per-draw pass-encoder bind, so the field swap below IS the
// publication, and the next pass reads the new store.
if (migration.Kind == BufferKind.Vertices)
{
_vertexBuffer = migration.NewBuffer;
@ -908,11 +762,10 @@ public sealed class GlobalMeshBuffer : IDisposable
/// <summary>
/// The arena's own flight gate — <see cref="_retirementLedger"/> and the abort
/// ticket — already proves no submitted frame can reference the store, so the
/// physical delete runs here rather than being deferred a second time by
/// <see cref="IGpuBuffer.Dispose"/>. Stages match
/// <c>TrackedGlResource.CreateRetryableBufferDeletion</c> exactly: precondition,
/// mutation-with-validation, byte accounting, then resource-count accounting,
/// so a driver failure re-issues only the delete and never double-counts.
/// physical delete runs here rather than being deferred a second time.
/// Stages: precondition (no-op), mutation (dispose), byte accounting, then
/// resource-count accounting, so a failure re-issues only the delete and
/// never double-counts.
/// </summary>
private RetryableGpuResourceRelease CreateRetryableStoreDeletion(
IGpuBuffer buffer,
@ -921,24 +774,9 @@ public sealed class GlobalMeshBuffer : IDisposable
{
ArgumentNullException.ThrowIfNull(buffer);
ArgumentOutOfRangeException.ThrowIfNegative(capacityBytes);
GL? gl = _gl;
return new RetryableGpuResourceRelease(
() =>
{
if (gl is not null)
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
},
// On GL the delete runs here rather than through IGpuBuffer.Dispose
// because the arena's own flight gate has already proven no submitted
// frame can reference the store. A backend with no GL context has no
// second deferral to skip: Dispose IS its retirement-queued release.
() =>
{
if (gl is not null)
RequireGlBuffer(buffer).DeleteRetired(context);
else
buffer.Dispose();
},
() => { },
() => buffer.Dispose(),
() =>
{
if (capacityBytes != 0)
@ -1020,16 +858,6 @@ public sealed class GlobalMeshBuffer : IDisposable
releases.Add(("staged-migration-buffer", release.Run));
}
if (VAO != 0 && _gl is { } vaoGl)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableVertexArrayDeletion(
vaoGl,
VAO,
$"deleting global mesh vertex array {VAO}",
GlobalMeshVaoAccounting.TrackDeallocation);
releases.Add(("global-vao", release.Run));
}
if (_vertexBuffer is { } vertexStore)
{
RetryableGpuResourceRelease release =
@ -1058,7 +886,6 @@ public sealed class GlobalMeshBuffer : IDisposable
_migration = null;
_migrationAbort = null;
VAO = 0;
_vertexBuffer = null;
_indexBuffer = null;
_disposeResources = null;

View file

@ -14,11 +14,11 @@ namespace AcDream.App.Rendering.Wb;
/// retirement queue, the shared instance VBO, and two capability flags. Seven
/// members out of a 760-line class.</para>
///
/// <para>So the coupling is expressed as an interface at exactly that surface
/// and <see cref="OpenGLGraphicsDevice"/> declares it — every member already
/// existed, so the GL arm executes not one changed statement. What this buys is
/// that <c>ObjectMeshManager</c> and <c>WbMeshAdapter</c> no longer NAME a
/// backend, which is the prerequisite for the slice that gives them a second
/// <para>So the coupling is expressed as an interface at exactly that surface,
/// and <c>OpenGLGraphicsDevice</c> declared it — every member already existed,
/// so the GL arm executed not one changed statement. What this buys is that
/// <c>ObjectMeshManager</c> and <c>WbMeshAdapter</c> no longer NAME a backend,
/// which is the prerequisite for the slice that gives them a second
/// implementation.</para>
///
/// <para><b>What slice V6i-3 then moved.</b> V6i-2 left the upload bodies raw —
@ -27,10 +27,16 @@ namespace AcDream.App.Rendering.Wb;
/// GL but not RUN. The arena now builds its stores through
/// <c>IGpuDevice.CreateBuffer</c> and publishes them as
/// <c>GlobalMeshBuffer.VertexStore</c>/<c>IndexStore</c>, which a pass encoder
/// binds; the vertex array is built only where one exists. What still reads
/// <see cref="Gl"/> is the LEGACY per-mesh upload the N.5 ship amendment made
/// unreachable, and <c>AcDream.App.Rendering.Gpu.Vk.VulkanMeshPipelineDevice</c>
/// is the second implementation this interface was cut for.</para>
/// binds; the vertex array is built only where one exists.
/// <see cref="AcDream.App.Rendering.Gpu.Vk.VulkanMeshPipelineDevice"/> is the
/// second implementation this interface was cut for.</para>
///
/// <para><b>Campaign V slice V11</b> deleted <c>OpenGLGraphicsDevice</c> along
/// with the rest of the raw-GL arm it fronted, so
/// <see cref="AcDream.App.Rendering.Gpu.Vk.VulkanMeshPipelineDevice"/> is now
/// the interface's only implementation. <see cref="Gl"/> always answers null
/// there; removing it (and the legacy per-mesh upload bodies it alone still
/// gated) is Campaign V's package/shader cleanup slice, not this one.</para>
/// </summary>
internal interface IMeshPipelineDevice : IDisposable
{

View file

@ -1,104 +0,0 @@
using Chorizite.Core.Render;
using Silk.NET.OpenGL;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace AcDream.App.Rendering.Wb {
/// <summary>
/// Implementation of a framebuffer for OpenGL ES 3.0 using Silk.NET.
/// </summary>
public class ManagedGLFramebuffer : IFramebuffer {
private readonly OpenGLGraphicsDevice _device;
private GL _gl => _device.GL;
private readonly uint _fboId;
private readonly uint _depthStencilRenderbuffer; // 0 if not used
private readonly ITexture _texture;
private readonly int _width;
private readonly int _height;
public ITexture Texture => _texture;
public IntPtr NativeHandle => new IntPtr(_fboId);
public ManagedGLFramebuffer(OpenGLGraphicsDevice device, ITexture texture, int width, int height, bool hasDepthStencil) {
_device = device;
_texture = texture;
_width = width;
_height = height;
// Generate and bind the framebuffer
_fboId = _gl.GenFramebuffer();
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.FBO);
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fboId);
// Attach the texture as the color attachment
_gl.FramebufferTexture2D(
FramebufferTarget.Framebuffer,
FramebufferAttachment.ColorAttachment0,
TextureTarget.Texture2D,
(uint)texture.NativePtr.ToInt32(),
0
);
// Create and attach a depth-stencil renderbuffer if requested
if (true || hasDepthStencil) {
_depthStencilRenderbuffer = _gl.GenRenderbuffer();
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.RBO);
_gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, _depthStencilRenderbuffer);
_gl.RenderbufferStorage(
RenderbufferTarget.Renderbuffer,
InternalFormat.Depth24Stencil8,
(uint)width,
(uint)height
);
_gl.FramebufferRenderbuffer(
FramebufferTarget.Framebuffer,
FramebufferAttachment.DepthStencilAttachment,
RenderbufferTarget.Renderbuffer,
_depthStencilRenderbuffer
);
GpuMemoryTracker.TrackAllocation(_width * _height * 4, GpuResourceType.RBO); // Depth24Stencil8 is 4 bytes per pixel
}
// Check framebuffer completeness
var status = _gl.CheckFramebufferStatus(FramebufferTarget.Framebuffer);
if (status != GLEnum.FramebufferComplete) {
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, 0);
_gl.DeleteFramebuffer(_fboId);
if (_depthStencilRenderbuffer != 0) {
_gl.DeleteRenderbuffer(_depthStencilRenderbuffer);
}
throw new InvalidOperationException($"Framebuffer creation failed: {status}");
}
var error = _gl.GetError();
if (error != GLEnum.NoError) {
throw new InvalidOperationException($"OpenGL error during framebuffer setup: {error}");
}
// Unbind the framebuffer
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, 0);
}
public void Dispose() {
var fboId = _fboId;
var depthStencilRenderbuffer = _depthStencilRenderbuffer;
var width = _width;
var height = _height;
_device.QueueGLAction(gl => {
if (fboId != 0) {
gl.DeleteFramebuffer(fboId);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.FBO);
}
if (depthStencilRenderbuffer != 0) {
gl.DeleteRenderbuffer(depthStencilRenderbuffer);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.RBO);
GpuMemoryTracker.TrackDeallocation(width * height * 4, GpuResourceType.RBO);
}
});
}
}
}

View file

@ -1,184 +0,0 @@
using Chorizite.Core.Render.Enums;
using Chorizite.Core.Render.Vertex;
using Silk.NET.OpenGL;
using BufferUsage = Chorizite.Core.Render.Enums.BufferUsage;
namespace AcDream.App.Rendering.Wb {
/// <summary>
/// OpenGL index buffer
/// </summary>
public unsafe class ManagedGLIndexBuffer : IIndexBuffer {
private uint bufferId;
private readonly OpenGLGraphicsDevice _device;
private void* _mappedPtr;
private GL GL => _device.GL;
/// <inheritdoc />
public int Size { get; private set; }
/// <inheritdoc />
public BufferUsage Usage { get; private set; }
/// <summary>
/// Initializes a new instance of the <see cref="ManagedGLIndexBuffer"/> class.
/// </summary>
/// <param name="usage">Buffer usage</param>
/// <param name="size">The size of the buffer, in bytes</param>
public unsafe ManagedGLIndexBuffer(OpenGLGraphicsDevice device, BufferUsage usage, int size) {
_device = device;
Size = size;
Usage = usage;
// Generate the buffer
bufferId = GL.GenBuffer();
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer);
GLHelpers.CheckErrors(GL);
// Allocate the buffer with the specified size but no initial data
GL.BindBuffer(GLEnum.ElementArrayBuffer, bufferId);
GLHelpers.CheckErrors(GL);
if (_device.HasBufferStorage) {
var flags = BufferStorageMask.MapWriteBit | BufferStorageMask.MapPersistentBit | BufferStorageMask.MapCoherentBit | BufferStorageMask.DynamicStorageBit;
GL.BufferStorage(GLEnum.ElementArrayBuffer, (uint)Size, (void*)0, flags);
_mappedPtr = GL.MapBufferRange(GLEnum.ElementArrayBuffer, 0, (nuint)Size, MapBufferAccessMask.WriteBit | MapBufferAccessMask.PersistentBit | MapBufferAccessMask.CoherentBit);
} else {
GL.BufferData(BufferTargetARB.ElementArrayBuffer, (uint)Size, (void*)0, Usage.ToGL());
}
GLHelpers.CheckErrors(GL);
GpuMemoryTracker.TrackAllocation(Size, GpuResourceType.Buffer);
}
/// <inheritdoc />
public void SetData(uint[] data) {
SetData(data.AsSpan());
}
/// <inheritdoc />
public unsafe void SetData(Span<uint> data) {
uint dataSize = (uint)data.Length * sizeof(uint);
// Ensure the buffer size is sufficient
if (dataSize > Size) {
throw new ArgumentException($"Data size ({dataSize} bytes) exceeds buffer size ({Size} bytes).");
}
if (_mappedPtr != null) {
Span<uint> mappedSpan = new Span<uint>(_mappedPtr, data.Length);
data.CopyTo(mappedSpan);
} else {
GL.BindBuffer(GLEnum.ElementArrayBuffer, bufferId);
GLHelpers.CheckErrors(GL);
fixed (uint* dataPtr = &data[0]) {
GL.BufferData(GLEnum.ElementArrayBuffer, dataSize, (void*)dataPtr, Usage.ToGL());
}
GLHelpers.CheckErrors(GL);
GL.BindBuffer(GLEnum.ElementArrayBuffer, 0);
GLHelpers.CheckErrors(GL);
}
}
/// <inheritdoc />
public unsafe void SetSubData(Span<uint> data, int destinationOffsetBytes, int sourceOffsetElements = 0, int lengthElements = 0) {
if (Usage != BufferUsage.Dynamic) {
throw new InvalidOperationException("Cannot update a buffer that is not dynamic.");
}
if (lengthElements <= 0) {
lengthElements = data.Length - sourceOffsetElements;
}
uint dataSizeBytes = (uint)lengthElements * sizeof(uint);
if (dataSizeBytes == 0) {
return;
}
// Make sure we're not trying to write past the end of the buffer
if (destinationOffsetBytes + dataSizeBytes > Size) {
throw new ArgumentException($"Update would exceed buffer size. Buffer size: {Size}, Update range: {destinationOffsetBytes} to {destinationOffsetBytes + dataSizeBytes}");
}
if (_mappedPtr != null) {
Span<uint> mappedSpan = new Span<uint>((byte*)_mappedPtr + destinationOffsetBytes, lengthElements);
data.Slice(sourceOffsetElements, lengthElements).CopyTo(mappedSpan);
} else {
GL.BindBuffer(GLEnum.ElementArrayBuffer, bufferId);
GLHelpers.CheckErrors(GL);
fixed (uint* dataPtr = &data[sourceOffsetElements]) {
GL.BufferSubData(
GLEnum.ElementArrayBuffer,
destinationOffsetBytes,
dataSizeBytes,
(void*)dataPtr);
GLHelpers.CheckErrors(GL);
}
}
}
/// <inheritdoc />
public unsafe void SetSubData(uint[] data, int destinationOffsetBytes, int sourceOffsetElements = 0, int lengthElements = 0) {
if (Usage != BufferUsage.Dynamic) {
throw new InvalidOperationException("Cannot update a buffer that is not dynamic.");
}
if (lengthElements <= 0) {
lengthElements = data.Length - sourceOffsetElements;
}
uint dataSizeBytes = (uint)lengthElements * sizeof(uint);
if (dataSizeBytes == 0) {
return;
}
// Make sure we're not trying to write past the end of the buffer
if (destinationOffsetBytes + dataSizeBytes > Size) {
throw new ArgumentException($"Update would exceed buffer size. Buffer size: {Size}, Update range: {destinationOffsetBytes} to {destinationOffsetBytes + dataSizeBytes}");
}
GL.BindBuffer(GLEnum.ElementArrayBuffer, bufferId);
GLHelpers.CheckErrors(GL);
fixed (uint* dataPtr = &data[sourceOffsetElements]) {
GL.BufferSubData(
GLEnum.ElementArrayBuffer,
destinationOffsetBytes,
dataSizeBytes,
(void*)dataPtr);
GLHelpers.CheckErrors(GL);
}
}
/// <inheritdoc />
public void Bind() {
RenderStateCache.CurrentIBO = 0;
GL.BindBuffer(GLEnum.ElementArrayBuffer, bufferId);
GLHelpers.CheckErrors(GL);
}
/// <inheritdoc />
public void Unbind() {
GL.BindBuffer(GLEnum.ElementArrayBuffer, 0);
GLHelpers.CheckErrors(GL);
}
public unsafe void Dispose() {
_device.QueueGLAction(GL => {
if (bufferId != 0) {
GL.DeleteBuffer(bufferId);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer);
GLHelpers.CheckErrors(GL);
GpuMemoryTracker.TrackDeallocation(Size, GpuResourceType.Buffer);
bufferId = 0;
_mappedPtr = null;
}
});
}
}
}

View file

@ -1,165 +0,0 @@
using Chorizite.Core.Render;
using Chorizite.Core.Render.Enums;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering.Wb {
public unsafe class ManagedGLTexture : ITexture {
private uint _texture;
private readonly OpenGLGraphicsDevice _device;
private GL GL => (_device as OpenGLGraphicsDevice).GL;
/// <inheritdoc/>
public IntPtr NativePtr => (IntPtr)_texture;
/// <inheritdoc/>
public int Width { get; private set; }
/// <inheritdoc/>
public int Height { get; private set; }
public TextureFormat Format => TextureFormat.RGBA8;
/// <inheritdoc/>
public ManagedGLTexture(OpenGLGraphicsDevice device, byte[]? source, int width, int height, TextureParameters? texParams = null) {
var p = texParams ?? TextureParameters.Default;
_device = device;
_texture = GL.GenTexture();
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Texture);
Width = width;
Height = height;
GL.BindTexture(GLEnum.Texture2D, _texture);
GLHelpers.CheckErrors(GL);
int maxDimension = Math.Max(width, height);
int mipLevels = (int)Math.Floor(Math.Log2(maxDimension)) + 1;
if (_device.HasTextureStorage) {
GL.TexStorage2D(GLEnum.Texture2D, (uint)mipLevels, GLEnum.Rgba8, (uint)width, (uint)height);
GLHelpers.CheckErrors(GL);
}
else {
GL.TexImage2D(GLEnum.Texture2D, 0, (int)InternalFormat.Rgba8, (uint)width, (uint)height, 0, PixelFormat.Rgba, (PixelType)0x1401, (void*)0);
GLHelpers.CheckErrors(GL);
}
GL.TexParameter(GLEnum.Texture2D, TextureParameterName.TextureWrapS, (int)p.WrapS);
GL.TexParameter(GLEnum.Texture2D, TextureParameterName.TextureWrapT, (int)p.WrapT);
GL.TexParameter(GLEnum.Texture2D, TextureParameterName.TextureMinFilter, (int)p.MinFilter);
GL.TexParameter(GLEnum.Texture2D, TextureParameterName.TextureMagFilter, (int)p.MagFilter);
GLHelpers.CheckErrors(GL);
if (p.EnableAnisotropicFiltering && _device.RenderSettings.EnableAnisotropicFiltering)
{
if (_device.MaxSupportedAnisotropy > 0)
{
GL.TexParameter(GLEnum.Texture2D, GLEnum.TextureMaxAnisotropy,
_device.MaxSupportedAnisotropy);
}
}
if (p.EnableMipmaps) {
GL.GenerateMipmap(GLEnum.Texture2D);
}
GLHelpers.CheckErrors(GL);
GL.BindTexture(GLEnum.Texture2D, 0);
GLHelpers.CheckErrors(GL);
GpuMemoryTracker.TrackAllocation(CalculateSize(), GpuResourceType.Texture);
}
private long CalculateSize() {
int maxDimension = Math.Max(Width, Height);
int mipLevels = (int)Math.Floor(Math.Log2(maxDimension)) + 1;
long totalSize = 0;
for (int i = 0; i < mipLevels; i++) {
int w = Math.Max(1, Width >> i);
int h = Math.Max(1, Height >> i);
totalSize += (long)w * h * 4;
}
return totalSize;
}
/// <inheritdoc/>
public ManagedGLTexture(OpenGLGraphicsDevice device, string file) {
throw new NotImplementedException();
}
public void SetData(Rectangle rectangle, byte[] data) {
if (_texture == 0) return;
GLHelpers.CheckErrors(GL);
GL.GetInteger(GLEnum.ActiveTexture, out int oldActiveTexture);
RenderStateCache.CurrentAtlas = 0;
GL.GetInteger(GLEnum.TextureBinding2D, out int oldBinding);
GL.BindTexture(GLEnum.Texture2D, _texture);
fixed (byte* ptr = data) {
GL.TexSubImage2D(
GLEnum.Texture2D,
0, // level
rectangle.X,
rectangle.Y,
(uint)rectangle.Width,
(uint)rectangle.Height,
PixelFormat.Rgba,
PixelType.UnsignedByte,
ptr
);
}
// Generate mipmaps if needed
GL.GenerateMipmap(GLEnum.Texture2D);
GL.BindTexture(GLEnum.Texture2D, (uint)oldBinding);
GL.ActiveTexture((GLEnum)oldActiveTexture);
GLHelpers.CheckErrors(GL);
}
public void Bind(int slot = 0) {
if (slot == 0) {
RenderStateCache.CurrentAtlas = 0;
}
GL.GetInteger(GLEnum.ActiveTexture, out int oldActiveTexture);
GLEnum targetTextureUnit = GLEnum.Texture0 + slot;
bool changedUnit = (GLEnum)oldActiveTexture != targetTextureUnit;
if (changedUnit) {
GL.ActiveTexture(targetTextureUnit);
}
GL.BindSampler((uint)slot, 0);
GL.BindTexture(GLEnum.Texture2D, (uint)NativePtr);
if (changedUnit) {
GL.ActiveTexture((GLEnum)oldActiveTexture);
}
GLHelpers.CheckErrors(GL);
}
public void Unbind() {
GL.BindTexture(GLEnum.Texture2D, 0);
GLHelpers.CheckErrors(GL);
}
protected void ReleaseTexture() {
_device.QueueGLAction(GL => {
if (_texture != 0) {
GL.DeleteTexture(_texture);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Texture);
GpuMemoryTracker.TrackDeallocation(CalculateSize(), GpuResourceType.Texture);
}
GLHelpers.CheckErrors(GL);
_texture = 0;
});
}
public void Dispose() {
ReleaseTexture();
}
}
}

View file

@ -1,705 +0,0 @@
using AcDream.Core.Rendering.Wb;
using Chorizite.Core.Render;
using Chorizite.Core.Render.Enums;
// Use our extracted TextureHelpers (T3), not the WB original — disambiguate explicitly
using TextureHelpers = AcDream.Core.Rendering.Wb.TextureHelpers;
using Microsoft.Extensions.Logging;
using Silk.NET.OpenGL;
using System.Runtime.InteropServices;
using AcDream.App.Rendering;
namespace AcDream.App.Rendering.Wb {
public class ManagedGLTextureArray : ITextureArray, IWorldTextureArray {
private readonly bool[] _usedLayers;
private readonly GL GL;
private readonly OpenGLGraphicsDevice _device;
private readonly ILogger _logger;
/// <summary>
/// Campaign V slice V6i-2: the device whose one texture table this
/// array's two resident handles are interned into. Before this slice
/// <c>ObjectMeshManager</c> read the handles off this object and did the
/// interning itself; a 64-bit bindless handle cannot cross to Vulkan, so
/// the array now answers <see cref="ResolveSlot"/> instead. Null only
/// for the legacy <c>OpenGLGraphicsDevice.CreateTextureArrayInternal</c>
/// entry points, which no shared atlas uses.
/// </summary>
private readonly AcDream.App.Rendering.Gpu.Gl.GlGpuDevice? _worldTextureTable;
private static int _nextId = 0;
private bool _needsMipmapRegeneration = false;
private readonly bool _isCompressed;
private int _mipmapDirtyCount = 0;
private readonly object _mipmapLock = new object();
private readonly List<TextureLayerUpdate> _pendingUpdates = new();
private int _disposeQueued;
private int _disposePublicationQueued;
private int _disposeRetirementAccepted;
private RetryableGpuResourceRelease? _disposeRelease;
private struct TextureLayerUpdate {
public int Layer;
public required byte[] Data;
public PixelFormat? UploadPixelFormat;
public PixelType? UploadPixelType;
}
public int Slot { get; } = _nextId++;
public int Width { get; private set; }
public int Height { get; private set; }
public int Size { get; private set; }
public TextureFormat Format { get; private set; }
public nint NativePtr { get; private set; }
public ulong BindlessWrapHandle { get; private set; }
public ulong BindlessClampHandle { get; private set; }
public long TotalSizeInBytes => CalculateTotalSize();
/// <summary>
/// #105 diagnostic: staged layer updates (retained decoded payloads) not yet
/// applied to the GL texture by <see cref="ProcessDirtyUpdates"/>. Layers with
/// a pending update sample UNDEFINED content (TexStorage3D contents) until the
/// flush runs — a stuck non-zero count at standstill is the white-walls mechanism.
/// </summary>
public int PendingUpdateCount {
get { lock (_mipmapLock) { return _pendingUpdates.Count; } }
}
public ManagedGLTextureArray(OpenGLGraphicsDevice graphicsDevice, TextureFormat format, int width, int height,
int size, ILogger logger, TextureParameters? texParams = null)
: this(graphicsDevice, format, width, height, size, logger, worldTextureTable: null, texParams) {
}
internal ManagedGLTextureArray(OpenGLGraphicsDevice graphicsDevice, TextureFormat format, int width, int height,
int size, ILogger logger,
AcDream.App.Rendering.Gpu.Gl.GlGpuDevice? worldTextureTable,
TextureParameters? texParams = null) {
_worldTextureTable = worldTextureTable;
var p = texParams ?? TextureParameters.Default;
if (width <= 0 || height <= 0 || size <= 0) {
throw new ArgumentException($"Invalid texture array dimensions: {width}x{height}x{size}");
}
Format = format;
Width = width;
Height = height;
Size = size;
_usedLayers = new bool[size];
_device = graphicsDevice;
GL = graphicsDevice.GL;
_logger = logger;
_isCompressed = IsCompressedFormat(format);
GLHelpers.CheckErrors(GL);
uint textureName = 0;
ulong wrapHandle = 0;
ulong clampHandle = 0;
bool textureTracked = false;
bool textureBytesTracked = false;
bool wrapResident = false;
bool clampResident = false;
long textureBytes = CalculateTotalSize();
try {
textureName = GL.GenTexture();
if (textureName == 0)
throw new InvalidOperationException("Failed to generate texture array.");
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Texture);
textureTracked = true;
GL.BindTexture(GLEnum.Texture2DArray, textureName);
int maxDimension = Math.Max(width, height);
int mipLevels = (int)Math.Floor(Math.Log2(maxDimension)) + 1;
GL.TexStorage3D(GLEnum.Texture2DArray, (uint)mipLevels, format.ToGL(), (uint)width, (uint)height,
(uint)size);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMinFilter,
(int)p.MinFilter);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMaxLevel, mipLevels - 1);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMagFilter, (int)p.MagFilter);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureWrapS, (int)p.WrapS);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureWrapT, (int)p.WrapT);
if (p.EnableAnisotropicFiltering
&& graphicsDevice.RenderSettings.EnableAnisotropicFiltering
&& graphicsDevice.MaxSupportedAnisotropy > 0) {
GL.TexParameter(
GLEnum.Texture2DArray,
GLEnum.TextureMaxAnisotropy,
graphicsDevice.MaxSupportedAnisotropy);
}
if (format == TextureFormat.A8) {
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleR, (int)GLEnum.One);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleG, (int)GLEnum.One);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleB, (int)GLEnum.One);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleA, (int)GLEnum.Red);
}
GLHelpers.ThrowOnResourceError(
GL,
$"creating texture array {format} {width}x{height}x{size} ({mipLevels} mip levels)");
GpuMemoryTracker.TrackAllocation(textureBytes, GpuResourceType.Texture);
textureBytesTracked = true;
if (_device.HasBindless && _device.BindlessExtension != null) {
wrapHandle = _device.BindlessExtension.GetTextureSamplerHandle(textureName, _device.WrapSampler);
clampHandle = _device.BindlessExtension.GetTextureSamplerHandle(textureName, _device.ClampSampler);
_device.BindlessExtension.MakeTextureHandleResident(wrapHandle);
wrapResident = true;
_device.BindlessExtension.MakeTextureHandleResident(clampHandle);
clampResident = true;
GLHelpers.ThrowOnResourceError(GL, "making texture-array sampler handles resident");
}
NativePtr = (nint)textureName;
BindlessWrapHandle = wrapHandle;
BindlessClampHandle = clampHandle;
}
catch (Exception constructionFailure) {
// Constructor failure cannot use Dispose: the object was never
// published and queued teardown would make retries accumulate
// invalid resident handles. Attempt every independent cleanup.
List<Exception>? cleanupFailures = null;
void Attempt(Action cleanup) {
try { cleanup(); }
catch (Exception ex) { (cleanupFailures ??= []).Add(ex); }
}
if (_device.BindlessExtension != null) {
if (clampResident)
Attempt(() => {
_device.BindlessExtension.MakeTextureHandleNonResident(clampHandle);
GLHelpers.ThrowOnResourceError(GL, "rolling back clamp texture-array handle");
clampResident = false;
});
if (wrapResident)
Attempt(() => {
_device.BindlessExtension.MakeTextureHandleNonResident(wrapHandle);
GLHelpers.ThrowOnResourceError(GL, "rolling back wrap texture-array handle");
wrapResident = false;
});
}
// Deleting a texture while either bindless sampler handle is
// still resident is undefined. A pre-commit residency failure
// therefore retains the texture instead of risking a driver
// reset during constructor rollback.
if (textureName != 0 && !clampResident && !wrapResident)
Attempt(() => {
GL.DeleteTexture(textureName);
GLHelpers.ThrowOnResourceError(GL, "rolling back texture array");
if (textureBytesTracked)
GpuMemoryTracker.TrackDeallocation(textureBytes, GpuResourceType.Texture);
if (textureTracked)
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Texture);
});
if (cleanupFailures is not null) {
cleanupFailures.Insert(0, constructionFailure);
throw new AggregateException(
"Texture-array construction and rollback both failed.",
cleanupFailures);
}
throw;
}
finally {
GL.ActiveTexture(TextureUnit.Texture0);
GL.BindTexture(GLEnum.Texture2DArray, 0);
RenderStateCache.CurrentAtlas = 0;
}
}
public long CalculateTotalSize() {
int maxDimension = Math.Max(Width, Height);
int mipLevels = (int)Math.Floor(Math.Log2(maxDimension)) + 1;
long layerSize = GetExpectedDataSize();
long totalSize = 0;
for (int i = 0; i < mipLevels; i++) {
int w = Math.Max(1, Width >> i);
int h = Math.Max(1, Height >> i);
if (_isCompressed) {
totalSize += TextureHelpers.GetCompressedLayerSize(w, h, Format) * Size;
}
else {
totalSize += (long)w * h * (layerSize / (Width * Height)) * Size;
}
}
return totalSize;
}
private static bool IsCompressedFormat(TextureFormat format) {
return format == TextureFormat.DXT1 ||
format == TextureFormat.DXT3 ||
format == TextureFormat.DXT5;
}
public void Bind(int slot = 0) {
if (NativePtr == 0) {
return;
}
GL.GetInteger(GLEnum.ActiveTexture, out int oldActiveTexture);
GLEnum targetTextureUnit = GLEnum.Texture0 + slot;
bool changedUnit = (GLEnum)oldActiveTexture != targetTextureUnit;
if (changedUnit) {
GL.ActiveTexture(targetTextureUnit);
}
GL.BindSampler((uint)slot, 0);
GL.BindTexture(GLEnum.Texture2DArray, (uint)NativePtr);
if (changedUnit) {
GL.ActiveTexture((GLEnum)oldActiveTexture);
}
GLHelpers.CheckErrors(GL);
}
public unsafe int AddLayer(byte[] data) {
return AddLayer(data, null, null);
}
public unsafe int AddLayer(byte[] data, PixelFormat? uploadPixelFormat, PixelType? uploadPixelType) {
for (int i = 0; i < _usedLayers.Length; i++) {
if (!_usedLayers[i]) {
UpdateLayerInternal(i, data, uploadPixelFormat, uploadPixelType);
_usedLayers[i] = true;
return i;
}
}
throw new InvalidOperationException(
$"No free layers available in texture array (Slot={Slot}, Size={Width}x{Height}x{Size}).");
}
public unsafe int AddLayer(Span<byte> data) {
return AddLayer(data.ToArray());
}
public void UpdateLayer(int layer, byte[] data) {
UpdateLayer(layer, data, null, null);
}
public void UpdateLayer(int layer, byte[] data, PixelFormat? uploadPixelFormat, PixelType? uploadPixelType) {
UpdateLayerInternal(layer, data, uploadPixelFormat, uploadPixelType);
_usedLayers[layer] = true;
}
private unsafe void UpdateLayerInternal(int layer, byte[] data, PixelFormat? uploadPixelFormat,
PixelType? uploadPixelType) {
if (NativePtr == 0) {
throw new InvalidOperationException("Texture array not created.");
}
if (layer < 0 || layer >= Size) {
throw new ArgumentOutOfRangeException(nameof(layer),
$"Layer index {layer} is out of range [0, {Size - 1}] (Slot={Slot}).");
}
ValidateUploadPayload(
Format,
Width,
Height,
data.Length,
uploadPixelFormat,
uploadPixelType);
lock (_mipmapLock) {
// Retain the immutable decoded payload until the once-per-frame
// atlas flush. The former per-atlas PBO permanently reserved
// several MiB for every array and duplicated each upload
// through BufferSubData before TexSubImage3D.
var update = new TextureLayerUpdate {
Layer = layer,
Data = data,
UploadPixelFormat = uploadPixelFormat,
UploadPixelType = uploadPixelType
};
int existingIndex = _pendingUpdates.FindLastIndex(pending => pending.Layer == layer);
if (existingIndex >= 0)
_pendingUpdates[existingIndex] = update;
else
_pendingUpdates.Add(update);
_needsMipmapRegeneration = true;
if (existingIndex < 0)
_mipmapDirtyCount++;
}
}
public long ProcessDirtyUpdates() {
lock (_mipmapLock) {
return ProcessDirtyUpdatesInternal(generateMipmaps: true);
}
}
private unsafe long ProcessDirtyUpdatesInternal(bool generateMipmaps) {
if (_pendingUpdates.Count == 0
&& (!generateMipmaps || !_needsMipmapRegeneration)) return 0;
long generatedBytes = 0;
GLHelpers.CheckErrors(GL);
// This runs in WbMeshAdapter.Tick before any draw pass. Establish
// the upload phase's canonical texture state directly instead of
// synchronously querying driver state for every dirty array.
GL.ActiveTexture(TextureUnit.Texture0);
RenderStateCache.CurrentAtlas = 0;
bool mipmapWorkCompleted = false;
try {
GL.BindTexture(GLEnum.Texture2DArray, (uint)NativePtr);
if (_pendingUpdates.Count > 0) {
// A non-zero pixel-unpack binding changes pointer arguments
// into byte offsets. Direct client-memory uploads therefore
// establish the canonical zero binding once for the batch.
GL.BindBuffer(GLEnum.PixelUnpackBuffer, 0);
GL.PixelStore(PixelStoreParameter.UnpackAlignment, 1);
GL.PixelStore(PixelStoreParameter.UnpackRowLength, 0);
GL.PixelStore(PixelStoreParameter.UnpackSkipRows, 0);
GL.PixelStore(PixelStoreParameter.UnpackSkipPixels, 0);
foreach (var update in _pendingUpdates) {
fixed (byte* data = update.Data) {
if (_isCompressed) {
var internalFormat = Format.ToCompressedGL();
GL.CompressedTexSubImage3D(
GLEnum.Texture2DArray,
0,
0,
0,
update.Layer,
(uint)Width,
(uint)Height,
1,
internalFormat,
(uint)update.Data.Length,
data);
}
else {
var pixelFormat = update.UploadPixelFormat ?? Format.ToPixelFormat();
var pixelType = update.UploadPixelType ?? Format.ToPixelType();
GL.TexSubImage3D(
GLEnum.Texture2DArray,
0,
0,
0,
update.Layer,
(uint)Width,
(uint)Height,
1,
pixelFormat,
pixelType,
data);
}
}
}
}
if (generateMipmaps && _needsMipmapRegeneration && _mipmapDirtyCount > 0) {
if (_isCompressed) {
_logger.LogDebug("Skipping automatic mipmap generation for compressed texture array (Slot={Slot})", Slot);
}
else {
try {
// Width, height and format were validated when the
// immutable storage was allocated. Re-reading them
// here forced three CPU/GPU synchronization points
// for every dirty atlas without adding safety.
GL.GenerateMipmap(GLEnum.Texture2DArray);
generatedBytes = TotalSizeInBytes;
}
catch (Exception ex) {
_logger.LogWarning(ex, "Failed to generate mipmaps for texture array (Slot={Slot}); retaining upload state for retry.", Slot);
throw;
}
}
}
// Release builds must observe transfer/OOM/context errors
// before the pending offsets and dirty mip state are cleared.
// One check covers every layer in this array plus its single
// mip generation, keeping the synchronization cost bounded by
// dirty arrays rather than uploaded textures.
GLHelpers.ThrowOnResourceError(
GL,
$"committing texture-array updates (Slot={Slot}, Layers={_pendingUpdates.Count})");
mipmapWorkCompleted = generateMipmaps
&& _needsMipmapRegeneration
&& _mipmapDirtyCount > 0;
}
finally {
GL.BindBuffer(GLEnum.PixelUnpackBuffer, 0);
GL.BindTexture(GLEnum.Texture2DArray, 0);
GL.ActiveTexture(TextureUnit.Texture0);
}
// Commit CPU-side completion only after glGetError confirms the
// uploads/mipmap work succeeded. If the driver rejects an
// operation, the retained payloads and dirty flags remain intact and the
// atlas stays in ObjectMeshManager's dirty set for a later retry.
_pendingUpdates.Clear();
if (mipmapWorkCompleted) {
_mipmapDirtyCount = 0;
_needsMipmapRegeneration = false;
}
return generatedBytes;
}
private void ClearLayerForMipmap(int layer) {
// Upload a single black/transparent pixel to make layer defined
byte[] clearData = new byte[GetExpectedDataSize()];
Array.Clear(clearData, 0, clearData.Length); // Zero-fill (black/transparent)
UpdateLayerInternal(layer, clearData, null, null);
}
private int GetExpectedDataSize() {
return CalculateExpectedDataSize(Format, Width, Height);
}
internal static int CalculateExpectedDataSize(TextureFormat format, int width, int height) {
if (IsCompressedFormat(format))
return TextureHelpers.GetCompressedLayerSize(width, height, format);
return format switch {
TextureFormat.RGBA8 => checked(width * height * 4),
TextureFormat.RGB8 => checked(width * height * 3),
TextureFormat.A8 => checked(width * height),
TextureFormat.Rgba32f => checked(width * height * 16),
_ => throw new NotSupportedException($"Unsupported format {format}")
};
}
internal static void ValidateUploadPayload(
TextureFormat format,
int width,
int height,
int dataLength,
PixelFormat? uploadPixelFormat,
PixelType? uploadPixelType) {
int expectedBytes = CalculateExpectedDataSize(format, width, height);
if (dataLength != expectedBytes) {
throw new ArgumentException(
$"Texture-array layer payload has {dataLength} bytes; expected exactly {expectedBytes} "
+ $"for {format} {width}x{height}.",
nameof(dataLength));
}
if (IsCompressedFormat(format)) {
if (uploadPixelFormat.HasValue || uploadPixelType.HasValue)
throw new ArgumentException("Compressed texture uploads cannot specify pixel format/type overrides.");
return;
}
PixelFormat expectedFormat = format.ToPixelFormat();
PixelType expectedType = format.ToPixelType();
if ((uploadPixelFormat ?? expectedFormat) != expectedFormat
|| (uploadPixelType ?? expectedType) != expectedType) {
throw new ArgumentException(
$"Upload descriptor {uploadPixelFormat}/{uploadPixelType} does not match "
+ $"the {expectedFormat}/{expectedType} transfer required by {format}.");
}
}
public void RemoveLayer(int layer) {
if (layer < 0 || layer >= Size) {
throw new ArgumentOutOfRangeException(nameof(layer),
$"Layer index {layer} is out of range [0, {Size - 1}] (Slot={Slot}).");
}
if (!_usedLayers[layer]) {
throw new InvalidOperationException($"Layer {layer} is already free (Slot={Slot}).");
}
_usedLayers[layer] = false;
// An unreferenced layer needs no clear or whole-array mip
// regeneration before AddTexture overwrites it on reuse.
}
public bool IsLayerUsed(int layer) {
if (layer < 0 || layer >= Size) return false;
return _usedLayers[layer];
}
public int GetUsedLayerCount() {
return _usedLayers.Count(x => x);
}
/// <summary>
/// True once disposal is durably owned by a queued GL publication,
/// the frame-retirement queue, or a completed retained release. A
/// caller may only commit its own logical disposal after this becomes
/// true; otherwise a synchronous enqueue failure still needs retry.
/// </summary>
internal bool HasDurableDisposeOwnership {
get {
if (Volatile.Read(ref _disposeQueued) == 0)
return false;
return Volatile.Read(ref _disposePublicationQueued) != 0
|| Volatile.Read(ref _disposeRetirementAccepted) != 0
|| Volatile.Read(ref _disposeRelease) is null;
}
}
/// <summary>
/// True only after every retained bindless-handle, GL-name, and memory
/// accounting release stage has completed. Logical disposal can become
/// durable earlier while the frame fence still owns the physical array.
/// </summary>
internal bool IsPhysicalRetirementComplete =>
Volatile.Read(ref _disposeQueued) != 0
&& Volatile.Read(ref _disposeRelease) is null;
bool IWorldTextureArray.HasDurableDisposeOwnership => HasDurableDisposeOwnership;
bool IWorldTextureArray.IsPhysicalRetirementComplete => IsPhysicalRetirementComplete;
/// <summary>
/// Campaign V slice V6i-2: this array's device-table slot for the
/// requested address mode.
///
/// <para>The interning call is the one <c>ObjectMeshManager</c> made
/// itself before this slice, moved one level down so the caller can be
/// written against <see cref="IWorldTextureArray"/> instead of against a
/// 64-bit <c>ARB_bindless_texture</c> handle that has no Vulkan
/// spelling. It is idempotent by handle, which is why it stays a per-batch
/// call rather than becoming cached state — exactly as before.</para>
/// </summary>
AcDream.App.Rendering.Gpu.GpuTextureSlot IWorldTextureArray.ResolveSlot(bool wrapping) {
if (_worldTextureTable is null)
return AcDream.App.Rendering.Gpu.GpuTextureSlot.Unassigned;
ulong handle = wrapping ? _retiredWrapHandle : _retiredClampHandle;
if (handle == 0)
handle = wrapping ? BindlessWrapHandle : BindlessClampHandle;
return _worldTextureTable.RegisterWorldTextureHandle(handle);
}
/// <summary>
/// Retires both table entries. <see cref="Dispose"/> zeroes the public
/// handle properties, so the values are captured there and read from the
/// captures here — this is called after physical retirement completes,
/// which is necessarily after Dispose.
/// </summary>
public void ReleaseTextureSlots() {
if (_worldTextureTable is null)
return;
_worldTextureTable.ReleaseWorldTextureHandle(_retiredWrapHandle);
_worldTextureTable.ReleaseWorldTextureHandle(_retiredClampHandle);
_retiredWrapHandle = 0;
_retiredClampHandle = 0;
}
private ulong _retiredWrapHandle;
private ulong _retiredClampHandle;
public void Unbind() {
GL.BindTexture(GLEnum.Texture2DArray, 0);
GLHelpers.CheckErrors(GL);
}
public void GenerateMipmaps() {
_needsMipmapRegeneration = true;
lock (_mipmapLock) {
_mipmapDirtyCount++;
}
}
public void Dispose() {
if (Interlocked.CompareExchange(ref _disposeQueued, 1, 0) != 0) {
ScheduleDisposeRelease();
return;
}
uint textureName = (uint)NativePtr;
ulong bindlessWrapHandle = BindlessWrapHandle;
ulong bindlessClampHandle = BindlessClampHandle;
long textureBytes = CalculateTotalSize();
// Slice V6i-2: the handles the two table entries are keyed by. The
// properties are zeroed below, so ReleaseTextureSlots — which runs
// only once physical retirement completes — reads these captures.
_retiredWrapHandle = bindlessWrapHandle;
_retiredClampHandle = bindlessClampHandle;
NativePtr = 0;
BindlessWrapHandle = 0;
BindlessClampHandle = 0;
_disposeRelease = new RetryableGpuResourceRelease(
() => {
if (_device.BindlessExtension != null && bindlessWrapHandle != 0)
GLHelpers.ThrowOnResourceError(GL, "releasing wrap texture-array handle (precondition)");
},
() => {
if (_device.BindlessExtension != null && bindlessWrapHandle != 0) {
_device.BindlessExtension.MakeTextureHandleNonResident(bindlessWrapHandle);
GLHelpers.ThrowOnResourceError(GL, "releasing wrap texture-array handle");
}
},
() => {
if (_device.BindlessExtension != null && bindlessClampHandle != 0)
GLHelpers.ThrowOnResourceError(GL, "releasing clamp texture-array handle (precondition)");
},
() => {
if (_device.BindlessExtension != null && bindlessClampHandle != 0) {
_device.BindlessExtension.MakeTextureHandleNonResident(bindlessClampHandle);
GLHelpers.ThrowOnResourceError(GL, "releasing clamp texture-array handle");
}
},
() => {
if (textureName != 0)
GLHelpers.ThrowOnResourceError(GL, $"deleting texture array {textureName} (precondition)");
},
() => {
if (textureName != 0) {
GL.DeleteTexture(textureName);
GLHelpers.ThrowOnResourceError(GL, $"deleting texture array {textureName}");
}
},
() => {
if (textureName != 0)
GpuMemoryTracker.TrackDeallocation(textureBytes, GpuResourceType.Texture);
},
() => {
if (textureName != 0)
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Texture);
},
() => _disposeRelease = null);
ScheduleDisposeRelease();
}
private void ScheduleDisposeRelease(bool forNextPass = false) {
RetryableGpuResourceRelease? release = _disposeRelease;
if (release is null || release.IsComplete || Volatile.Read(ref _disposeRetirementAccepted) != 0)
return;
if (Interlocked.CompareExchange(ref _disposePublicationQueued, 1, 0) != 0)
return;
try {
Action<GL> publish = GL => {
Volatile.Write(ref _disposePublicationQueued, 0);
try {
_device.RetireGpuResource(release.Run);
Volatile.Write(ref _disposeRetirementAccepted, 1);
}
catch {
// Retire may fail before accepting the callback, or an
// immediate queue may surface a partial release. The
// release cursor makes this next-pass retry exact.
ScheduleDisposeRelease(forNextPass: true);
throw;
}
};
if (forNextPass)
_device.QueueGLActionForNextPass(publish);
else
_device.QueueGLAction(publish);
}
catch {
Volatile.Write(ref _disposePublicationQueued, 0);
throw;
}
}
}
}

View file

@ -1,177 +0,0 @@
using Chorizite.Core.Render;
using Chorizite.Core.Render.Enums;
using Silk.NET.OpenGL;
using System.Runtime.InteropServices;
using BufferUsage = Chorizite.Core.Render.Enums.BufferUsage;
// IUniformBuffer is in Chorizite.Core.dll but under the Chorizite.OpenGLSDLBackend namespace
using IUniformBuffer = Chorizite.OpenGLSDLBackend.IUniformBuffer;
namespace AcDream.App.Rendering.Wb {
/// <summary>
/// OpenGL uniform buffer
/// </summary>
public unsafe class ManagedGLUniformBuffer : IUniformBuffer {
private uint bufferId;
private readonly OpenGLGraphicsDevice _device;
private GL GL => _device.GL;
/// <inheritdoc />
public int Size { get; private set; }
/// <inheritdoc />
public BufferUsage Usage { get; private set; }
/// <summary>
/// Initializes a new instance of the <see cref="ManagedGLUniformBuffer"/> class.
/// </summary>
/// <param name="device">Graphics device</param>
/// <param name="usage">Buffer usage</param>
/// <param name="size">The size of the buffer, in bytes</param>
public unsafe ManagedGLUniformBuffer(OpenGLGraphicsDevice device, BufferUsage usage, int size) {
_device = device ?? throw new ArgumentNullException(nameof(device));
ArgumentOutOfRangeException.ThrowIfLessThan(size, 1);
Size = size;
Usage = usage;
var resources = new AcDream.App.Rendering.ResourceCleanupGroup();
uint buffer = 0;
bool allocated = false;
try {
buffer = TrackedGlResource.CreateBuffer(
GL,
"creating managed uniform buffer");
uint ownedBuffer = buffer;
RetryableGpuResourceRelease unpublishedBufferRelease =
TrackedGlResource.CreateRetryableBufferDeletion(
GL,
ownedBuffer,
() => allocated ? Size : 0,
"rolling back managed uniform buffer");
resources.Add(
"managed uniform buffer",
unpublishedBufferRelease.Run);
TrackedGlResource.AllocateBufferStorage(
GL,
GLEnum.UniformBuffer,
buffer,
0,
Size,
GLEnum.DynamicDraw,
"allocating managed uniform buffer");
allocated = true;
resources.TransferAll();
} catch (Exception constructionFailure) {
resources.RollbackConstructionAndThrow(
"ManagedGLUniformBuffer construction failed and its GL buffer did not cleanly roll back.",
constructionFailure);
}
bufferId = buffer;
}
/// <inheritdoc />
public unsafe void SetData<T>(T[] data) where T : unmanaged {
SetData(data.AsSpan());
}
/// <inheritdoc />
public unsafe void SetData<T>(Span<T> data) where T : unmanaged {
uint dataSize = (uint)data.Length * (uint)Marshal.SizeOf<T>();
// Ensure the buffer size is sufficient
if (dataSize > Size) {
throw new ArgumentException($"Data size ({dataSize} bytes) exceeds buffer size ({Size} bytes).");
}
GL.BindBuffer(GLEnum.UniformBuffer, bufferId);
fixed (T* ptr = data) {
GL.BufferSubData(GLEnum.UniformBuffer, 0, (nuint)dataSize, ptr);
}
}
/// <inheritdoc />
public unsafe void SetSubData<T>(T[] data, int destinationOffsetBytes, int sourceOffsetElements = 0, int lengthElements = 0) where T : unmanaged {
SetSubData(data.AsSpan(), destinationOffsetBytes, sourceOffsetElements, lengthElements);
}
/// <inheritdoc />
public unsafe void SetSubData<T>(Span<T> data, int destinationOffsetBytes, int sourceOffsetElements = 0, int lengthElements = 0) where T : unmanaged {
if (lengthElements <= 0) {
lengthElements = data.Length - sourceOffsetElements;
}
uint dataSizeBytes = (uint)lengthElements * (uint)Marshal.SizeOf<T>();
// Validate buffer bounds
if (destinationOffsetBytes + dataSizeBytes > Size) {
throw new ArgumentException($"Update would exceed buffer size. Buffer size: {Size}, Update range: {destinationOffsetBytes} to {destinationOffsetBytes + dataSizeBytes}");
}
GL.BindBuffer(GLEnum.UniformBuffer, bufferId);
fixed (T* ptr = data.Slice(sourceOffsetElements, lengthElements)) {
GL.BufferSubData(GLEnum.UniformBuffer, (nint)destinationOffsetBytes, (nuint)dataSizeBytes, ptr);
}
}
/// <summary>
/// Sets a single piece of data in the buffer.
/// </summary>
public unsafe void SetData<T>(ref T data) where T : unmanaged {
fixed (T* pData = &data) {
SetData(new Span<T>(pData, 1));
}
}
/// <summary>
/// Binds the buffer to the specified binding point.
/// </summary>
/// <param name="bindingPoint">The binding point to bind to</param>
public void Bind(uint bindingPoint) {
GL.BindBufferBase(GLEnum.UniformBuffer, bindingPoint, bufferId);
GLHelpers.CheckErrors(GL);
}
/// <inheritdoc />
public void Bind() {
GL.BindBuffer(GLEnum.UniformBuffer, bufferId);
GLHelpers.CheckErrors(GL);
}
/// <inheritdoc />
public void Unbind() {
GL.BindBuffer(GLEnum.UniformBuffer, 0);
GLHelpers.CheckErrors(GL);
}
public void Dispose() {
_device.QueueGLAction(GL => {
if (bufferId != 0) {
GL.DeleteBuffer(bufferId);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer);
GLHelpers.CheckErrors(GL);
GpuMemoryTracker.TrackDeallocation(Size, GpuResourceType.Buffer);
bufferId = 0;
}
});
}
/// <summary>
/// Releases an unpublished constructor-owned buffer synchronously on
/// the GL thread. This is deliberately separate from ordinary queued
/// disposal so an enclosing constructor can prove rollback before it
/// propagates its failure.
/// </summary>
internal void DisposeImmediately() {
if (bufferId == 0)
return;
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
GL,
bufferId,
Size,
"rolling back unpublished managed uniform buffer");
release.Run();
bufferId = 0;
}
}
}

View file

@ -1,77 +0,0 @@
using Chorizite.Core.Render.Enums;
using Chorizite.Core.Render.Vertex;
using Silk.NET.OpenGL;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using VertexAttribType = Silk.NET.OpenGL.VertexAttribType;
namespace AcDream.App.Rendering.Wb {
public unsafe class ManagedGLVertexArray : IVertexArray {
private readonly OpenGLGraphicsDevice _device;
private GL GL => _device.GL;
private uint _vaoId = 0;
public ManagedGLVertexArray(OpenGLGraphicsDevice device, IVertexBuffer buffer, VertexFormat format) {
_device = device;
// Generate the vertex array
_vaoId = GL.GenVertexArray();
GLHelpers.CheckErrors(GL);
if (_vaoId == 0) {
throw new Exception("Failed to generate vertex array.");
}
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.VAO);
SetVertexBuffer(buffer, format);
}
public void SetVertexBuffer(IVertexBuffer buffer, VertexFormat format) {
GL.BindVertexArray(_vaoId);
GLHelpers.CheckErrors(GL);
buffer.Bind();
for (int i = 0; i < format.Attributes.Length; i++) {
var attr = format.Attributes[i];
GL.EnableVertexAttribArray((uint)i);
GLHelpers.CheckErrors(GL);
GL.VertexAttribPointer((uint)i, attr.Size, Convert(attr.Type), attr.Normalized, (uint)format.Stride, attr.Offset);
GLHelpers.CheckErrors(GL);
}
GL.BindVertexArray(0);
GLHelpers.CheckErrors(GL);
}
private GLEnum Convert(Chorizite.Core.Render.Enums.VertexAttribType type) => type switch {
Chorizite.Core.Render.Enums.VertexAttribType.Float => GLEnum.Float,
Chorizite.Core.Render.Enums.VertexAttribType.Int => GLEnum.Int,
Chorizite.Core.Render.Enums.VertexAttribType.UnsignedInt => GLEnum.UnsignedInt,
Chorizite.Core.Render.Enums.VertexAttribType.UnsignedByte => GLEnum.UnsignedByte,
Chorizite.Core.Render.Enums.VertexAttribType.Byte => GLEnum.Byte,
_ => throw new NotSupportedException()
};
public void Bind() {
GL.BindVertexArray(_vaoId);
GLHelpers.CheckErrors(GL);
}
public void Unbind() {
GL.BindVertexArray(0);
GLHelpers.CheckErrors(GL);
}
public void Dispose() {
_device.QueueGLAction(GL => {
if (_vaoId != 0) {
GL.DeleteVertexArray(_vaoId);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.VAO);
_vaoId = 0;
}
GLHelpers.CheckErrors(GL);
});
}
}
}

View file

@ -1,185 +0,0 @@
using Chorizite.Core.Render.Enums;
using Chorizite.Core.Render.Vertex;
using Microsoft.Extensions.Logging;
using Silk.NET.OpenGL;
using System.Buffers;
using System.Runtime.InteropServices;
using BufferUsage = Chorizite.Core.Render.Enums.BufferUsage;
namespace AcDream.App.Rendering.Wb {
/// <summary>
/// OpenGL vertex buffer
/// </summary>
public unsafe class ManagedGLVertexBuffer : IVertexBuffer {
private uint bufferId;
private readonly OpenGLGraphicsDevice _device;
private void* _mappedPtr;
private GL GL => _device.GL;
/// <inheritdoc />
public int Size { get; private set; }
/// <inheritdoc />
public BufferUsage Usage { get; private set; }
/// <summary>
/// Initializes a new instance of the <see cref="ManagedGLVertexBuffer"/> class.
/// </summary>
/// <param name="usage">Buffer usage</param>
/// <param name="size">The size of the buffer, in bytes</param>
public unsafe ManagedGLVertexBuffer(OpenGLGraphicsDevice device, BufferUsage usage, int size) {
_device = device;
Size = size;
Usage = usage;
// Generate the buffer
bufferId = GL.GenBuffer();
if (bufferId == 0) {
throw new Exception("Failed to generate vertex buffer.");
}
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer);
GLHelpers.CheckErrors(GL);
// Allocate the buffer with the specified size but no initial data
GL.BindBuffer(GLEnum.ArrayBuffer, bufferId);
GLHelpers.CheckErrors(GL);
if (_device.HasBufferStorage) {
var flags = BufferStorageMask.MapWriteBit | BufferStorageMask.MapPersistentBit | BufferStorageMask.MapCoherentBit | BufferStorageMask.DynamicStorageBit;
GL.BufferStorage(GLEnum.ArrayBuffer, (uint)Size, (void*)0, flags);
_mappedPtr = GL.MapBufferRange(GLEnum.ArrayBuffer, 0, (nuint)Size, MapBufferAccessMask.WriteBit | MapBufferAccessMask.PersistentBit | MapBufferAccessMask.CoherentBit);
} else {
GL.BufferData(
GLEnum.ArrayBuffer,
(uint)Size,
(void*)0, // No initial data
Usage.ToGL());
}
GLHelpers.CheckErrors(GL);
GpuMemoryTracker.TrackAllocation(Size, GpuResourceType.Buffer);
}
/// <inheritdoc />
public unsafe void SetData<T>(T[] data) where T : IVertex {
SetData(data.AsSpan());
}
/// <inheritdoc />
public unsafe void SetData<T>(Span<T> data) where T : IVertex {
uint dataSize = (uint)data.Length * (uint)Marshal.SizeOf<T>();
// Ensure the buffer size is sufficient
if (dataSize > Size) {
throw new ArgumentException($"Data size ({dataSize} bytes) exceeds buffer size ({Size} bytes).");
}
if (_mappedPtr != null) {
Span<T> mappedSpan = new Span<T>(_mappedPtr, data.Length);
data.CopyTo(mappedSpan);
} else {
GL.BindBuffer(GLEnum.ArrayBuffer, bufferId);
GLHelpers.CheckErrors(GL);
// Map the buffer for writing
void* mappedPtr = GL.MapBufferRange(
GLEnum.ArrayBuffer,
0, // offset
dataSize,
MapBufferAccessMask.WriteBit | MapBufferAccessMask.InvalidateBufferBit // Overwrite entire buffer
);
if (mappedPtr == null) {
throw new Exception("Failed to map buffer for writing.");
}
try {
// Copy data directly to mapped memory
Span<T> mappedSpan = new Span<T>(mappedPtr, data.Length);
data.CopyTo(mappedSpan);
}
finally {
// Unmap the buffer
GL.UnmapBuffer(GLEnum.ArrayBuffer);
GLHelpers.CheckErrors(GL);
}
}
}
public unsafe void SetSubData<T>(T[] data, int destinationOffsetBytes, int sourceOffsetElements = 0, int lengthElements = 0) where T : IVertex {
SetSubData(data.AsSpan(), destinationOffsetBytes, sourceOffsetElements, lengthElements);
}
/// <inheritdoc />
public unsafe void SetSubData<T>(Span<T> data, int destinationOffsetBytes, int sourceOffsetElements = 0, int lengthElements = 0) where T : IVertex {
if (Usage != BufferUsage.Dynamic) {
throw new InvalidOperationException("Cannot update a buffer that is not dynamic.");
}
if (lengthElements <= 0) {
lengthElements = data.Length - sourceOffsetElements;
}
uint dataSizeBytes = (uint)lengthElements * (uint)Marshal.SizeOf<T>();
// Validate buffer bounds
if (destinationOffsetBytes + dataSizeBytes > Size) {
throw new ArgumentException($"Update would exceed buffer size. Buffer size: {Size}, Update range: {destinationOffsetBytes} to {destinationOffsetBytes + dataSizeBytes}");
}
if (_mappedPtr != null) {
Span<T> mappedSpan = new Span<T>((byte*)_mappedPtr + destinationOffsetBytes, lengthElements);
data.Slice(sourceOffsetElements, lengthElements).CopyTo(mappedSpan);
} else {
GL.BindBuffer(GLEnum.ArrayBuffer, bufferId);
GLHelpers.CheckErrors(GL);
// Map the specific range of the buffer
void* mappedPtr = GL.MapBufferRange(
GLEnum.ArrayBuffer,
destinationOffsetBytes,
dataSizeBytes,
MapBufferAccessMask.WriteBit // Write access for partial update
);
if (mappedPtr == null) {
throw new Exception("Failed to map buffer for writing.");
}
try {
// Copy the specified range of data to the mapped memory
Span<T> mappedSpan = new Span<T>(mappedPtr, lengthElements);
data.Slice(sourceOffsetElements, lengthElements).CopyTo(mappedSpan);
}
finally {
// Unmap the buffer
GL.UnmapBuffer(GLEnum.ArrayBuffer);
GLHelpers.CheckErrors(GL);
}
}
}
public void Bind() {
GL.BindBuffer(GLEnum.ArrayBuffer, bufferId);
GLHelpers.CheckErrors(GL);
}
public void Unbind() {
GL.BindBuffer(GLEnum.ArrayBuffer, 0);
GLHelpers.CheckErrors(GL);
}
public unsafe void Dispose() {
_device.QueueGLAction(GL => {
if (bufferId != 0) {
GL.DeleteBuffer(bufferId);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer);
GLHelpers.CheckErrors(GL);
GpuMemoryTracker.TrackDeallocation(Size, GpuResourceType.Buffer);
bufferId = 0;
_mappedPtr = null;
}
});
}
}
}

View file

@ -1,6 +1,4 @@
using System.Runtime.InteropServices;
using DatReaderWriter.Enums;
using Chorizite.Core.Render;
namespace AcDream.App.Rendering.Wb {
/// <summary>
@ -19,17 +17,4 @@ namespace AcDream.App.Rendering.Wb {
public uint Flags; // 4 bytes — reserved, matches mesh_modern.vert's BatchData.flags
}
public struct LandblockMdiCommand {
public ulong SortKey;
public ulong ObjectId;
public DrawElementsIndirectCommand Command;
public ModernBatchData BatchData;
public uint TextureIndex;
public ManagedGLTextureArray Atlas;
public uint VAO;
public uint IBO;
public bool IsTransparent;
public bool IsAdditive;
public bool HasWrappingUVs;
}
}

View file

@ -28,7 +28,17 @@ namespace AcDream.App.Rendering.Wb
/// </summary>
public class ObjectRenderData
{
/// <summary>
/// Campaign V slice V11 deleted the per-mesh raw-GL vertex array/buffer
/// this used to carry — Vulkan bakes vertex input into the pipeline and
/// the shared <see cref="GlobalMeshBuffer"/> arena has no VAO/VBO
/// concept at all (see <see cref="GlobalMeshBuffer.VertexStore"/>). This
/// is always 0 now; it survives only because
/// <c>WbDrawDispatcher.cs</c>'s legacy (non-RHI) dispatcher still reads
/// it into its own dead <c>anyVao</c> bookkeeping.
/// </summary>
public uint VAO { get; set; }
/// <summary>See <see cref="VAO"/> — always 0 for the same reason.</summary>
public uint VBO { get; set; }
public int VertexCount { get; set; }
public List<ObjectRenderBatch> Batches { get; set; } = new();
@ -76,6 +86,11 @@ namespace AcDream.App.Rendering.Wb
/// </summary>
public class ObjectRenderBatch
{
/// <summary>See <see cref="ObjectRenderData.VAO"/> — Campaign V slice
/// V11 deleted the legacy per-batch raw-GL index buffer this used to
/// carry. Always 0 now; every batch's actual index range lives in the
/// shared arena via <see cref="FirstIndex"/>/<see cref="BaseVertex"/>.
/// </summary>
public uint IBO { get; set; }
public int IndexCount { get; set; }
public TextureAtlasManager Atlas { get; set; } = null!;
@ -124,26 +139,6 @@ namespace AcDream.App.Rendering.Wb
/// </summary>
private readonly IMeshPipelineDevice _graphicsDevice;
/// <summary>
/// The GL context the LEGACY (pre-modern-path) upload bodies write
/// through.
///
/// <para>Campaign V slice V6i-3 narrowed what still needs it. The modern
/// path's arena upload is <see cref="GlobalMeshBuffer"/>'s, and that is
/// now <see cref="AcDream.App.Rendering.Gpu.IGpuBuffer"/> work on both
/// arms; what remains raw is the per-mesh VAO/VBO/IBO construction the
/// N.5 ship amendment made unreachable — missing bindless or
/// draw-parameters throws at startup, so <c>_useModernRendering</c> is
/// true in every shipping configuration. The accessor therefore survives
/// as the guard on genuinely dead code rather than as a blocker, and it
/// is deleted with that code.</para>
/// </summary>
private GL RequireGl() =>
_graphicsDevice.Gl
?? throw new InvalidOperationException(
"The mesh pipeline's legacy per-mesh vertex-array upload is raw GL and this "
+ "device has no context. The modern path is mandatory (N.5 ship amendment), "
+ "so reaching this is a composition error rather than a backend gap.");
private readonly IPreparedAssetSource _preparedAssets;
private readonly ILogger _logger;
@ -158,20 +153,6 @@ namespace AcDream.App.Rendering.Wb
/// </summary>
private readonly AcDream.App.Rendering.Gpu.IGpuDevice _gpuDevice;
/// <summary>
/// Campaign V slice V6i-2: the downcast moved here from the constructor.
/// Only the raw-GL world renderers reach this — the Vulkan backend binds
/// set 2 and never touches the handle table — so a Vulkan-composed mesh
/// pipeline can now be CONSTRUCTED, and only a caller that genuinely
/// needs a GL handle table fails, naming why.
/// </summary>
internal AcDream.App.Rendering.Gpu.Gl.GlGpuDevice WorldTextureTable =>
_gpuDevice as AcDream.App.Rendering.Gpu.Gl.GlGpuDevice
?? throw new InvalidOperationException(
"The GL bindless handle table was requested from a mesh pipeline composed against "
+ $"the {_gpuDevice.Backend} backend. It is GL-only emulation of the Vulkan texture "
+ "table and is deleted with the raw-GL world path.");
/// <summary>
/// Campaign V slice V6i-2: how a shared atlas's physical array is made.
/// Composed once; see <see cref="IWorldTextureArrayFactory"/>.
@ -339,7 +320,6 @@ namespace AcDream.App.Rendering.Wb
}
public GlobalMeshBuffer? GlobalBuffer { get; }
private readonly bool _useModernRendering;
internal (int RenderData, int AtlasArrays, int UnusedLru, long EstimatedBytes) Diagnostics
{
get
@ -527,11 +507,16 @@ namespace AcDream.App.Rendering.Wb
_stagedMeshData = new MeshUploadStagingQueue(
budgets.MeshStagingEntries,
budgets.MeshStagingBytes);
_useModernRendering = _graphicsDevice.HasOpenGL43 && _graphicsDevice.HasBindless;
if (_useModernRendering)
// The modern path is mandatory (N.5 ship amendment) and Campaign V
// slice V11 deleted the only other backend, so a production
// IMeshPipelineDevice always reports both flags true. The gate
// survives because AcDream.App.Tests.Rendering.Wb.
// MeshPipelineDeviceSeamTests exercises a device that reports
// neither, to prove the arena is genuinely optional rather than
// dereferenced unconditionally.
if (_graphicsDevice.HasOpenGL43 && _graphicsDevice.HasBindless)
{
GlobalBuffer = new GlobalMeshBuffer(
_graphicsDevice.Gl,
gpuDevice,
_graphicsDevice.ResourceRetirement);
}
@ -680,7 +665,7 @@ namespace AcDream.App.Rendering.Wb
/// <summary>
/// #105 diagnostic: counts staged-but-unflushed texture layer updates across all
/// shared atlases (see <see cref="ManagedGLTextureArray.PendingUpdateCount"/>).
/// shared atlases (see <see cref="IWorldTextureArray.PendingUpdateCount"/>).
/// Render thread only — <c>_globalAtlases</c> is render-thread-owned.
/// </summary>
public (int PendingUpdates, int ArraysWithPending, int TotalArrays) GetPendingTextureUpdateStats()
@ -901,7 +886,7 @@ namespace AcDream.App.Rendering.Wb
private long GetReclaimableBytes(ObjectRenderData data)
{
if (_useModernRendering && data.GlobalAllocation is { } allocation)
if (data.GlobalAllocation is { } allocation)
{
return checked(
(long)allocation.Vertices.Length * VertexPositionNormalTexture.Size
@ -1989,16 +1974,10 @@ namespace AcDream.App.Rendering.Wb
#region Private: GPU Upload
private unsafe ObjectRenderData? UploadGfxObjMeshData(ObjectMeshData meshData)
private ObjectRenderData? UploadGfxObjMeshData(ObjectMeshData meshData)
{
if (meshData.Vertices.Length == 0) return null;
// Resolved lazily since Campaign V slice V6i-3: every reader below
// is inside a !_useModernRendering branch, and the modern path is
// mandatory, so a backend with no GL context uploads meshes here
// without ever asking for one.
GL? gl = _graphicsDevice.Gl;
uint vao = 0, vbo = 0;
var modernIndexBatches = meshData.TextureBatches.Values
.SelectMany(batches => batches)
.Where(batch => batch.Indices.Count != 0)
@ -2007,62 +1986,23 @@ namespace AcDream.App.Rendering.Wb
GlobalMeshAllocation? globalAllocation = null;
var renderBatches = new List<ObjectRenderBatch>();
var acquiredTextures = new List<(TextureAtlasManager Atlas, TextureKey Key)>();
var legacyIndexBuffers = new List<(uint Name, int Bytes)>();
try
{
if (_useModernRendering)
{
// One mesh owns one vertex range and one contiguous index
// range. The former append path duplicated the full vertex
// array per material and never reclaimed evicted ranges.
vao = GlobalBuffer!.VAO;
vbo = GlobalBuffer!.VBO;
}
else
{
GL legacyGl = RequireGl();
legacyGl.GenVertexArrays(1, out vao);
legacyGl.BindVertexArray(vao);
legacyGl.GenBuffers(1, out vbo);
legacyGl.BindBuffer(GLEnum.ArrayBuffer, vbo);
fixed (VertexPositionNormalTexture* ptr = meshData.Vertices)
{
legacyGl.BufferData(GLEnum.ArrayBuffer, (nuint)(meshData.Vertices.Length * VertexPositionNormalTexture.Size), ptr, GLEnum.StaticDraw);
}
GpuMemoryTracker.TrackAllocation(meshData.Vertices.Length * VertexPositionNormalTexture.Size, GpuResourceType.Buffer);
int stride = VertexPositionNormalTexture.Size;
// Position (location 0)
legacyGl.EnableVertexAttribArray(0);
legacyGl.VertexAttribPointer(0, 3, GLEnum.Float, false, (uint)stride, (void*)0);
// Normal (location 1)
legacyGl.EnableVertexAttribArray(1);
legacyGl.VertexAttribPointer(1, 3, GLEnum.Float, false, (uint)stride, (void*)(3 * sizeof(float)));
// TexCoord (location 2)
legacyGl.EnableVertexAttribArray(2);
legacyGl.VertexAttribPointer(2, 2, GLEnum.Float, false, (uint)stride, (void*)(6 * sizeof(float)));
// Instance data (shared VBO)
legacyGl.BindBuffer(GLEnum.ArrayBuffer, _graphicsDevice.InstanceVBO);
for (uint i = 0; i < 4; i++)
{
var loc = 3 + i;
legacyGl.EnableVertexAttribArray(loc);
legacyGl.VertexAttribPointer(loc, 4, GLEnum.Float, false, (uint)sizeof(InstanceData), (void*)(i * 16));
legacyGl.VertexAttribDivisor(loc, 1);
}
legacyGl.EnableVertexAttribArray(8);
legacyGl.VertexAttribIPointer(8, 1, GLEnum.UnsignedInt, (uint)sizeof(InstanceData), (void*)64);
legacyGl.VertexAttribDivisor(8, 1);
}
// Allocate the shared vertex/index range before acquiring texture
// references. A buffer-growth failure therefore leaves every atlas
// untouched; later failures still roll this allocation back below.
if (_useModernRendering && modernIndexBatches.Length != 0)
globalAllocation = GlobalBuffer!.UploadMesh(meshData.Vertices, modernIndexBatches);
//
// GlobalBuffer is null only for a test double that reports no
// modern-path capability (MeshPipelineDeviceSeamTests); every
// production IMeshPipelineDevice is Vulkan-backed and reports both
// flags true (N.5 ship amendment; Campaign V slice V11 deleted the
// only other backend). There is no longer a per-mesh vertex array
// or vertex/index buffer to build here — Vulkan bakes vertex input
// into the pipeline, and the shared arena's stores are bound once
// per pass (see WbDrawDispatcher.Rhi.cs's BindPipelineWithMesh).
if (GlobalBuffer is not null && modernIndexBatches.Length != 0)
globalAllocation = GlobalBuffer.UploadMesh(meshData.Vertices, modernIndexBatches);
foreach (var (format, batches) in meshData.TextureBatches)
{
@ -2070,7 +2010,6 @@ namespace AcDream.App.Rendering.Wb
{
if (batch.Indices.Count == 0) continue;
uint ibo = 0;
TextureAtlasManager? atlasManager = null;
int textureIndex = 0;
uint firstIndex = 0;
@ -2128,24 +2067,6 @@ namespace AcDream.App.Rendering.Wb
if (uploadsNewLayer)
_dirtyAtlases.Add(atlasManager);
if (_useModernRendering)
{
ibo = GlobalBuffer!.IBO;
}
else
{
GL legacyGl = RequireGl();
legacyGl.GenBuffers(1, out ibo);
legacyGl.BindBuffer(GLEnum.ElementArrayBuffer, ibo);
var indexArray = batch.Indices.ToArray();
fixed (ushort* iptr = indexArray)
{
legacyGl.BufferData(GLEnum.ElementArrayBuffer, (nuint)(indexArray.Length * sizeof(ushort)), iptr, GLEnum.StaticDraw);
}
GpuMemoryTracker.TrackAllocation(indexArray.Length * sizeof(ushort), GpuResourceType.Buffer);
legacyIndexBuffers.Add((ibo, indexArray.Length * sizeof(ushort)));
}
// Campaign V slice V4t interned the atlas's resident
// handle into the device's one texture table here and
// carried the slot. Slice V6i-2 asks the array for the
@ -2159,7 +2080,6 @@ namespace AcDream.App.Rendering.Wb
renderBatches.Add(new ObjectRenderBatch
{
IBO = ibo,
IndexCount = batch.Indices.Count,
Atlas = atlasManager!,
TextureIndex = textureIndex,
@ -2178,7 +2098,7 @@ namespace AcDream.App.Rendering.Wb
}
}
if (_useModernRendering && globalAllocation is not null)
if (globalAllocation is not null)
{
if (renderBatches.Count != globalAllocation.BatchFirstIndices.Count)
{
@ -2197,8 +2117,6 @@ namespace AcDream.App.Rendering.Wb
+ renderBatches.Sum(b => (long)b.IndexCount * sizeof(ushort)));
var renderData = new ObjectRenderData
{
VAO = vao,
VBO = vbo,
VertexCount = meshData.Vertices.Length,
Batches = renderBatches,
GlobalAllocation = globalAllocation,
@ -2209,26 +2127,17 @@ namespace AcDream.App.Rendering.Wb
CPUEdgeLines = meshData.EdgeLines,
MemorySize = geometryBytes,
NonArenaGpuBytes = CalculateNonArenaGeometryBytes(
_useModernRendering,
GlobalBuffer is not null,
geometryBytes),
};
if (!_useModernRendering)
{
RequireGl().BindVertexArray(0);
}
return renderData;
}
catch (Exception uploadFailure)
{
RetryableResourceReleaseLedger rollback = CreateUploadRollback(
meshData,
gl,
vao,
vbo,
globalAllocation,
acquiredTextures,
legacyIndexBuffers);
acquiredTextures);
ResourceReleaseAttempt attempt = rollback.Advance();
if (!rollback.IsComplete)
{
@ -2248,13 +2157,8 @@ namespace AcDream.App.Rendering.Wb
}
private RetryableResourceReleaseLedger CreateUploadRollback(
ObjectMeshData meshData,
GL? gl,
uint vao,
uint vbo,
GlobalMeshAllocation? globalAllocation,
IReadOnlyList<(TextureAtlasManager Atlas, TextureKey Key)> acquiredTextures,
IReadOnlyList<(uint Name, int Bytes)> legacyIndexBuffers)
IReadOnlyList<(TextureAtlasManager Atlas, TextureKey Key)> acquiredTextures)
{
var releases = new List<(string Name, Action Release)>();
@ -2282,35 +2186,6 @@ namespace AcDream.App.Rendering.Wb
acquiredTextures[releaseIndex].Key)));
}
if (!_useModernRendering)
{
GL legacyGl = gl ?? RequireGl();
for (int i = 0; i < legacyIndexBuffers.Count; i++)
{
int bufferIndex = i;
releases.Add((
$"legacy-index-buffer-{bufferIndex}-delete",
() => legacyGl.DeleteBuffer(legacyIndexBuffers[bufferIndex].Name)));
releases.Add((
$"legacy-index-buffer-{bufferIndex}-accounting",
() => GpuMemoryTracker.TrackDeallocation(
legacyIndexBuffers[bufferIndex].Bytes,
GpuResourceType.Buffer)));
}
if (vbo != 0)
{
releases.Add(("legacy-vertex-buffer-delete", () => legacyGl.DeleteBuffer(vbo)));
releases.Add((
"legacy-vertex-buffer-accounting",
() => GpuMemoryTracker.TrackDeallocation(
meshData.Vertices.Length * VertexPositionNormalTexture.Size,
GpuResourceType.Buffer)));
}
if (vao != 0)
releases.Add(("legacy-vertex-array-delete", () => legacyGl.DeleteVertexArray(vao)));
}
return new RetryableResourceReleaseLedger(releases);
}
@ -2447,48 +2322,14 @@ namespace AcDream.App.Rendering.Wb
return null;
var releases = new List<(string Name, Action Release)>();
if (_useModernRendering)
if (data.GlobalAllocation is { } allocation)
{
if (data.GlobalAllocation is { } allocation)
{
releases.Add((
"global-index-range",
() => GlobalBuffer!.ReleaseIndexRange(allocation)));
releases.Add((
"global-vertex-range",
() => GlobalBuffer!.ReleaseVertexRange(allocation)));
}
}
else
{
GL gl = RequireGl();
if (data.VAO != 0)
releases.Add(("legacy-vertex-array-delete", () => gl.DeleteVertexArray(data.VAO)));
if (data.VBO != 0)
{
releases.Add(("legacy-vertex-buffer-delete", () => gl.DeleteBuffer(data.VBO)));
releases.Add((
"legacy-vertex-buffer-accounting",
() => GpuMemoryTracker.TrackDeallocation(
data.VertexCount * VertexPositionNormalTexture.Size,
GpuResourceType.Buffer)));
}
for (int i = 0; i < data.Batches.Count; i++)
{
int batchIndex = i;
ObjectRenderBatch batch = data.Batches[batchIndex];
if (batch.IBO == 0)
continue;
releases.Add((
$"legacy-index-buffer-{batchIndex}-delete",
() => gl.DeleteBuffer(data.Batches[batchIndex].IBO)));
releases.Add((
$"legacy-index-buffer-{batchIndex}-accounting",
() => GpuMemoryTracker.TrackDeallocation(
data.Batches[batchIndex].IndexCount * sizeof(ushort),
GpuResourceType.Buffer)));
}
releases.Add((
"global-index-range",
() => GlobalBuffer!.ReleaseIndexRange(allocation)));
releases.Add((
"global-vertex-range",
() => GlobalBuffer!.ReleaseVertexRange(allocation)));
}
for (int i = 0; i < data.Batches.Count; i++)
@ -2841,7 +2682,7 @@ namespace AcDream.App.Rendering.Wb
"One or more texture atlases could not be disposed.",
failures!);
if (_useModernRendering && GlobalBuffer is not null)
if (GlobalBuffer is not null)
Capture(ref failures, GlobalBuffer.Dispose);
if (failures is not null)

View file

@ -1,776 +0,0 @@
using Chorizite.Core.Render;
using Chorizite.Core.Render.Enums;
using Chorizite.Core.Render.Vertex;
using AcDream.App.Rendering;
using Microsoft.Extensions.Logging;
using Silk.NET.OpenGL;
// IUniformBuffer is in Chorizite.Core.dll but under the Chorizite.OpenGLSDLBackend namespace
using IUniformBuffer = Chorizite.OpenGLSDLBackend.IUniformBuffer;
using Silk.NET.OpenGL.Extensions.ARB;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Numerics;
using System.Runtime.InteropServices;
using System.Threading;
using PolygonMode = Silk.NET.OpenGL.PolygonMode;
using PrimitiveType = Silk.NET.OpenGL.PrimitiveType;
namespace AcDream.App.Rendering.Wb {
/// <summary>
/// OpenGL graphics device
/// </summary>
public unsafe class OpenGLGraphicsDevice : BaseGraphicsDevice, IMeshPipelineDevice {
private readonly ILogger _log;
private readonly DebugRenderSettings _renderSettings;
private readonly AcDream.App.Rendering.IGpuResourceRetirementQueue _resourceRetirement;
public GL GL { get; }
public DebugRenderSettings RenderSettings => _renderSettings;
private readonly ConcurrentQueue<Action<GL>> _glThreadQueue = new();
private readonly ConcurrentQueue<Action<GL>> _nextGlThreadQueue = new();
internal bool HasPendingGLWork =>
!_glThreadQueue.IsEmpty || !_nextGlThreadQueue.IsEmpty;
// Campaign V slice V6i-2: IMeshPipelineDevice. Every member below already
// existed under a GL-specific name; these are aliases, not behaviour, so
// the shipping backend executes exactly the statements it executed
// before. See the interface for what the mesh pipeline actually needs
// and what still has to move before it has a second implementation.
GL? IMeshPipelineDevice.Gl => GL;
AcDream.App.Rendering.IGpuResourceRetirementQueue IMeshPipelineDevice.ResourceRetirement =>
_resourceRetirement;
bool IMeshPipelineDevice.HasPendingWork => HasPendingGLWork;
void IMeshPipelineDevice.ProcessQueue() => ProcessGLQueue();
public void QueueGLAction(Action<GL> action) {
_glThreadQueue.Enqueue(action);
}
internal void QueueGLActionForNextPass(Action<GL> action) {
ArgumentNullException.ThrowIfNull(action);
_nextGlThreadQueue.Enqueue(action);
}
public void ProcessGLQueue() {
// Retry the prior pass before ordinary work (notably sampler
// deletion), but process only the captured generation so a
// persistent driver failure cannot spin this frame forever.
int retryCount = _nextGlThreadQueue.Count;
for (int i = 0; i < retryCount && _nextGlThreadQueue.TryDequeue(out Action<GL>? retry); i++) {
try {
retry(GL);
} catch (Exception ex) {
_log.LogError(ex, "Error processing retryable GL queue action");
}
}
// Normal actions retain drain-to-empty semantics because teardown
// actions intentionally enqueue dependent atlas releases here.
// A persistent retryable error must not starve unrelated uploads
// and releases forever: the retry generation remains bounded to
// one attempt per pass, while ordinary work still makes progress.
while (_glThreadQueue.TryDequeue(out var action)) {
try {
action(GL);
} catch (Exception ex) {
_log.LogError(ex, "Error processing GL queue action");
}
}
}
public bool HasBindless { get; private set; }
public bool HasOpenGL43 { get; private set; }
public bool HasBufferStorage { get; private set; }
public bool HasTextureStorage { get; private set; }
public ArbBindlessTexture? BindlessExtension { get; private set; }
public uint InstanceVBO { get; private set; }
public void* InstanceVBOPtr { get; private set; }
public uint SharedQuadVBO { get; private set; }
public uint SharedDebugVAO { get; private set; }
public uint SharedDebugInstanceVBO { get; private set; }
/// <summary>OpenGL sampler object with TextureWrapMode.Repeat (for meshes with wrapping UVs).</summary>
public uint WrapSampler { get; private set; }
/// <summary>OpenGL sampler object with TextureWrapMode.ClampToEdge (for meshes without wrapping UVs).</summary>
public uint ClampSampler { get; private set; }
internal float MaxSupportedAnisotropy { get; private set; }
private ManagedGLUniformBuffer? _sceneDataBuffer;
/// <summary>Shared SceneData UBO.</summary>
public ManagedGLUniformBuffer SceneDataBuffer => _sceneDataBuffer!;
private SceneData _currentSceneData;
public SceneData CurrentSceneData => _currentSceneData;
public void SetSceneData(ref SceneData data) {
_currentSceneData = data;
SceneDataBuffer.SetData(ref data);
}
private int _instanceBufferCapacity = 0;
private int _instanceBufferStride = 0;
/// <inheritdoc />
public override IntPtr NativeDevice { get; }
protected OpenGLGraphicsDevice() : base() {
_log = null!;
_renderSettings = null!;
_resourceRetirement = null!;
GL = null!;
}
public OpenGLGraphicsDevice(GL gl, ILogger log, DebugRenderSettings renderSettings, bool allowBindless = true)
: this(gl, log, renderSettings, AcDream.App.Rendering.ImmediateGpuResourceRetirementQueue.Instance, allowBindless) {
}
internal OpenGLGraphicsDevice(
GL gl,
ILogger log,
DebugRenderSettings renderSettings,
AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement,
bool allowBindless = true) : base() {
_log = log;
_renderSettings = renderSettings;
_resourceRetirement = resourceRetirement ?? throw new ArgumentNullException(nameof(resourceRetirement));
GL = gl;
GLHelpers.Init(this, log);
try {
GL.GetInteger(GLEnum.MajorVersion, out int major);
GL.GetInteger(GLEnum.MinorVersion, out int minor);
HasOpenGL43 = major > 4 || (major == 4 && minor >= 3);
HasTextureStorage = major > 4 || (major == 4 && minor >= 2) || GL.IsExtensionPresent("GL_ARB_texture_storage");
HasBufferStorage = major > 4 || (major == 4 && minor >= 4) || GL.IsExtensionPresent("GL_ARB_buffer_storage");
if (allowBindless && GL.TryGetExtension(out ArbBindlessTexture ext)) {
BindlessExtension = ext;
HasBindless = true;
} else {
HasBindless = false;
}
} catch {
HasOpenGL43 = false;
HasBindless = false;
}
var resources = new ResourceCleanupGroup();
try {
InstanceVBO = CreateConstructionBuffer(resources, "WB instance buffer");
// Query this immutable device limit once. Atlas construction can
// happen hundreds of times during portal streaming; repeating a
// driver GetFloat for every texture serialized the upload burst.
if (renderSettings.EnableAnisotropicFiltering) {
MaxSupportedAnisotropy = GlResourceCommand.Execute(
GL,
"query maximum texture anisotropy",
() => {
GL.GetFloat(GLEnum.MaxTextureMaxAnisotropy, out float maxAniso);
return Math.Max(0f, maxAniso);
});
}
WrapSampler = CreateConstructionSampler(
resources,
TextureWrapMode.Repeat,
"WB repeat sampler");
ClampSampler = CreateConstructionSampler(
resources,
TextureWrapMode.ClampToEdge,
"WB clamp sampler");
_sceneDataBuffer = new ManagedGLUniformBuffer(
this,
BufferUsage.Dynamic,
Marshal.SizeOf<SceneData>());
ManagedGLUniformBuffer ownedSceneDataBuffer = _sceneDataBuffer;
resources.Add(
"WB scene-data uniform buffer",
ownedSceneDataBuffer.DisposeImmediately);
InitializeSharedDebugResources(resources);
resources.TransferAll();
} catch (Exception constructionFailure) {
resources.RollbackConstructionAndThrow(
"OpenGLGraphicsDevice construction failed and its GL prefix did not cleanly roll back.",
constructionFailure);
}
}
/// <summary>
/// Retires a GL resource only after every submitted draw that could
/// reference it has completed on the GPU.
/// </summary>
internal void RetireGpuResource(Action release) => _resourceRetirement.Retire(release);
internal AcDream.App.Rendering.IGpuResourceRetirementQueue ResourceRetirement =>
_resourceRetirement;
private uint CreateConstructionBuffer(ResourceCleanupGroup resources, string name) {
uint buffer = GlResourceCommand.CreateName(GL, name, GL.GenBuffer, GL.DeleteBuffer);
resources.Add(
name,
() => GlResourceCommand.DeleteBuffer(
GL,
buffer,
$"delete {name} {buffer}"));
return buffer;
}
private uint CreateConstructionSampler(
ResourceCleanupGroup resources,
TextureWrapMode wrapMode,
string name) {
uint sampler = GlResourceCommand.CreateName(GL, name, GL.GenSampler, GL.DeleteSampler);
resources.Add(
name,
() => GlResourceCommand.Execute(
GL,
$"delete {name} {sampler}",
() => GL.DeleteSampler(sampler)));
GlResourceCommand.Execute(GL, $"configure {name}", () => {
GL.SamplerParameter(sampler, SamplerParameterI.WrapS, (int)wrapMode);
GL.SamplerParameter(sampler, SamplerParameterI.WrapT, (int)wrapMode);
GL.SamplerParameter(
sampler,
SamplerParameterI.MinFilter,
(int)TextureMinFilter.LinearMipmapLinear);
GL.SamplerParameter(
sampler,
SamplerParameterI.MagFilter,
(int)TextureMagFilter.Linear);
if (MaxSupportedAnisotropy > 0)
GL.SamplerParameter(
sampler,
GLEnum.TextureMaxAnisotropy,
MaxSupportedAnisotropy);
});
return sampler;
}
private void InitializeSharedDebugResources(ResourceCleanupGroup resources) {
// Unit quad vertices for two triangles (0 to 1 for length, -0.5 to 0.5 for thickness)
float[] quadVertices = {
0.0f, -0.5f,
1.0f, -0.5f,
1.0f, 0.5f,
0.0f, -0.5f,
1.0f, 0.5f,
0.0f, 0.5f
};
SharedQuadVBO = CreateConstructionBuffer(resources, "WB shared debug quad buffer");
SharedDebugInstanceVBO = CreateConstructionBuffer(
resources,
"WB shared debug instance buffer");
SharedDebugVAO = GlResourceCommand.CreateName(
GL,
"WB shared debug vertex array",
GL.GenVertexArray,
GL.DeleteVertexArray);
uint ownedDebugVao = SharedDebugVAO;
resources.Add(
"WB shared debug vertex array",
() => GlResourceCommand.DeleteVertexArray(
GL,
ownedDebugVao,
$"delete WB shared debug vertex array {ownedDebugVao}"));
GlResourceCommand.Execute(GL, "configure WB shared debug resources", () => {
GL.BindBuffer(GLEnum.ArrayBuffer, SharedQuadVBO);
fixed (float* pQuad = quadVertices) {
GL.BufferData(
GLEnum.ArrayBuffer,
(nuint)(quadVertices.Length * sizeof(float)),
pQuad,
GLEnum.StaticDraw);
}
// Initial capacity for debug instances.
GL.BindBuffer(GLEnum.ArrayBuffer, SharedDebugInstanceVBO);
GL.BufferData(
GLEnum.ArrayBuffer,
(nuint)(1024 * 44),
(void*)0,
GLEnum.StreamDraw); // 44 bytes is sizeof(LineInstance)
GL.BindVertexArray(SharedDebugVAO);
// Quad Pos attribute (location 0)
GL.BindBuffer(GLEnum.ArrayBuffer, SharedQuadVBO);
GL.EnableVertexAttribArray(0);
GL.VertexAttribPointer(0, 2, GLEnum.Float, false, 2 * sizeof(float), (void*)0);
// Instance attributes
GL.BindBuffer(GLEnum.ArrayBuffer, SharedDebugInstanceVBO);
uint lineInstanceSize = 44;
// aStart (location 1)
GL.EnableVertexAttribArray(1);
GL.VertexAttribPointer(1, 3, GLEnum.Float, false, lineInstanceSize, (void*)0);
GL.VertexAttribDivisor(1, 1);
// aEnd (location 2)
GL.EnableVertexAttribArray(2);
GL.VertexAttribPointer(2, 3, GLEnum.Float, false, lineInstanceSize, (void*)12);
GL.VertexAttribDivisor(2, 1);
// aColor (location 3)
GL.EnableVertexAttribArray(3);
GL.VertexAttribPointer(3, 4, GLEnum.Float, false, lineInstanceSize, (void*)24);
GL.VertexAttribDivisor(3, 1);
// aThickness (location 4)
GL.EnableVertexAttribArray(4);
GL.VertexAttribPointer(4, 1, GLEnum.Float, false, lineInstanceSize, (void*)40);
GL.VertexAttribDivisor(4, 1);
GL.BindVertexArray(0);
});
}
public void EnsureInstanceBufferCapacity(int count, int stride, bool forceOrphan = false) {
if (count <= _instanceBufferCapacity && !forceOrphan) return;
if (_instanceBufferCapacity > 0) {
GpuMemoryTracker.TrackDeallocation(_instanceBufferCapacity * _instanceBufferStride);
}
_instanceBufferCapacity = Math.Max(count, 256);
_instanceBufferStride = stride;
if (HasBufferStorage) {
if (InstanceVBO != 0) {
GL.DeleteBuffer(InstanceVBO);
}
GL.GenBuffers(1, out uint instanceVbo);
InstanceVBO = instanceVbo;
GL.BindBuffer(GLEnum.ArrayBuffer, InstanceVBO);
var flags = BufferStorageMask.MapWriteBit | BufferStorageMask.MapPersistentBit | BufferStorageMask.MapCoherentBit | BufferStorageMask.DynamicStorageBit;
GL.BufferStorage(GLEnum.ArrayBuffer, (nuint)(_instanceBufferCapacity * _instanceBufferStride), (void*)0, flags);
InstanceVBOPtr = GL.MapBufferRange(GLEnum.ArrayBuffer, 0, (nuint)(_instanceBufferCapacity * _instanceBufferStride), MapBufferAccessMask.WriteBit | MapBufferAccessMask.PersistentBit | MapBufferAccessMask.CoherentBit);
} else {
GL.BindBuffer(GLEnum.ArrayBuffer, InstanceVBO);
GL.BufferData(GLEnum.ArrayBuffer, (nuint)(_instanceBufferCapacity * _instanceBufferStride),
(void*)null, GLEnum.DynamicDraw);
InstanceVBOPtr = null;
}
GpuMemoryTracker.TrackAllocation(_instanceBufferCapacity * _instanceBufferStride);
}
public void UpdateInstanceBuffer<T>(List<T> data) where T : unmanaged {
EnsureInstanceBufferCapacity(data.Count, Marshal.SizeOf<T>(), true);
var span = CollectionsMarshal.AsSpan(data);
if (InstanceVBOPtr != null) {
var destSpan = new Span<T>(InstanceVBOPtr, data.Count);
span.CopyTo(destSpan);
} else {
GL.BindBuffer(GLEnum.ArrayBuffer, InstanceVBO);
fixed (T* ptr = span) {
GL.BufferSubData(GLEnum.ArrayBuffer, 0, (nuint)(data.Count * Marshal.SizeOf<T>()), ptr);
}
}
}
public void UpdateInstanceBuffer<T>(Span<T> data) where T : unmanaged {
EnsureInstanceBufferCapacity(data.Length, Marshal.SizeOf<T>(), true);
if (InstanceVBOPtr != null) {
var destSpan = new Span<T>(InstanceVBOPtr, data.Length);
data.CopyTo(destSpan);
} else {
GL.BindBuffer(GLEnum.ArrayBuffer, InstanceVBO);
fixed (T* ptr = data) {
GL.BufferSubData(GLEnum.ArrayBuffer, 0, (nuint)(data.Length * Marshal.SizeOf<T>()), ptr);
}
}
}
/// <inheritdoc />
public override void Clear(ColorVec color, ClearFlags flags, float depth, int stencil) {
GL.ClearColor(color.R, color.G, color.B, color.A);
GLHelpers.CheckErrors(GL);
GL.Clear((uint)Convert(flags));
GLHelpers.CheckErrors(GL);
}
/// <inheritdoc />
public override IIndexBuffer CreateIndexBuffer(int size,
Chorizite.Core.Render.Enums.BufferUsage usage = Chorizite.Core.Render.Enums.BufferUsage.Static) {
return new ManagedGLIndexBuffer(this, usage, size);
}
/// <inheritdoc />
public override IVertexBuffer CreateVertexBuffer(int size,
Chorizite.Core.Render.Enums.BufferUsage usage = Chorizite.Core.Render.Enums.BufferUsage.Static) {
return new ManagedGLVertexBuffer(this, usage, size);
}
/// <inheritdoc />
public override IVertexArray CreateArrayBuffer(IVertexBuffer vertexBuffer, VertexFormat format) {
return new ManagedGLVertexArray(this, vertexBuffer, format);
}
/// <inheritdoc />
public override void DrawElements(Chorizite.Core.Render.Enums.PrimitiveType type, int numElements, int indiceOffset = 0) {
GL.DrawElements(Convert(type), (uint)numElements, GLEnum.UnsignedInt, (void*)(indiceOffset * sizeof(uint)));
GLHelpers.CheckErrors(GL);
}
public override IShader CreateShader(string name, string vertexCode, string fragmentCode) {
var key = $"{GL.GetHashCode()}_{name}_{vertexCode.GetHashCode()}_{fragmentCode.GetHashCode()}";
while (true) {
if (_shaderCache.TryGetValue(key, out var existing)) {
if (existing is SharedShader shared && shared.TryIncrement()) {
return existing;
}
}
var inner = new GLSLShader(this, name, vertexCode, fragmentCode, _log);
var newShader = new SharedShader(inner, () => _shaderCache.TryRemove(key, out _));
if (_shaderCache.TryAdd(key, newShader)) {
return newShader;
}
// Someone else added it first, dispose ours and try again
newShader.DisposeInternal();
}
}
/// <inheritdoc />
public override IShader CreateShader(string name, string shaderDirectory) {
var key = $"{GL.GetHashCode()}_{name}";
while (true) {
if (_shaderCache.TryGetValue(key, out var existing)) {
if (existing is SharedShader shared && shared.TryIncrement()) {
return existing;
}
}
var inner = new GLSLShader(this, name, shaderDirectory, _log);
var newShader = new SharedShader(inner, () => _shaderCache.TryRemove(key, out _));
if (_shaderCache.TryAdd(key, newShader)) {
return newShader;
}
// Someone else added it first, dispose ours and try again
newShader.DisposeInternal();
}
}
private static readonly ConcurrentDictionary<string, IShader> _shaderCache = new();
private class SharedShader : IShader, IDisposable {
private readonly IShader _shader;
private readonly Action _onDispose;
private int _refCount = 1;
public string Name => _shader.Name;
public uint ProgramId => _shader.ProgramId;
public SharedShader(IShader shader, Action onDispose) {
_shader = shader;
_onDispose = onDispose;
}
public bool TryIncrement() {
while (true) {
int current = _refCount;
if (current <= 0) return false;
if (Interlocked.CompareExchange(ref _refCount, current + 1, current) == current) {
return true;
}
}
}
public void Bind() => _shader.Bind();
public void Unbind() => _shader.Unbind();
public void Load(string vertexSource, string fragmentSource) => _shader.Load(vertexSource, fragmentSource);
public void SetUniform(string name, int value) => _shader.SetUniform(name, value);
public void SetUniform(string name, float value) => _shader.SetUniform(name, value);
public void SetUniform(string name, Vector2 value) => _shader.SetUniform(name, value);
public void SetUniform(string name, Vector3 value) => _shader.SetUniform(name, value);
public void SetUniform(string name, Vector4 value) => _shader.SetUniform(name, value);
public void SetUniform(string name, Matrix4x4 value) => _shader.SetUniform(name, value);
public void SetUniform(string name, float[] values) => _shader.SetUniform(name, values);
public void DisposeInternal() {
_refCount = 0;
(_shader as IDisposable)?.Dispose();
}
public void Dispose() {
if (Interlocked.Decrement(ref _refCount) == 0) {
(_shader as IDisposable)?.Dispose();
_onDispose();
}
}
}
/// <inheritdoc />
public override ITexture
CreateTextureInternal(TextureFormat format, int width, int height, byte[]? data = null) {
if (format != TextureFormat.RGBA8) {
throw new NotImplementedException($"Texture format {format} is not supported.");
}
return new ManagedGLTexture(this, data, width, height);
}
/// <summary>
/// Creates a texture with custom texture parameters.
/// </summary>
public ITexture CreateTextureInternal(TextureFormat format, int width, int height, byte[]? data, TextureParameters texParams) {
if (format != TextureFormat.RGBA8) {
throw new NotImplementedException($"Texture format {format} is not supported.");
}
return new ManagedGLTexture(this, data, width, height, texParams);
}
/// <inheritdoc />
public override ITexture? CreateTextureInternal(TextureFormat format, string filename) {
if (format != TextureFormat.RGBA8) {
throw new NotImplementedException($"Texture format {format} is not supported.");
}
return new ManagedGLTexture(this, filename);
}
/// <inheritdoc />
public override ITextureArray
CreateTextureArrayInternal(TextureFormat format, int width, int height, int size) {
return new ManagedGLTextureArray(this, format, width, height, size, _log);
}
/// <summary>
/// Creates a texture array with custom texture parameters.
/// </summary>
public ITextureArray CreateTextureArrayInternal(TextureFormat format, int width, int height, int size, TextureParameters texParams) {
return new ManagedGLTextureArray(this, format, width, height, size, _log, texParams);
}
/// <inheritdoc />
public override void BeginFrame() {
GL.Viewport(Viewport.X, Viewport.Y, (uint)Viewport.Width, (uint)Viewport.Height);
GLHelpers.CheckErrors(GL);
GL.BindFramebuffer(FramebufferTarget.Framebuffer, 0);
GLHelpers.CheckErrors(GL);
}
/// <inheritdoc />
public override void EndFrame() {
}
/// <inheritdoc />
protected override void SetRenderStateInternal(RenderState state, bool enabled) {
switch (state) {
case RenderState.AlphaBlend:
if (enabled) GL.Enable(EnableCap.Blend);
else GL.Disable(EnableCap.Blend);
GLHelpers.CheckErrors(GL);
break;
case RenderState.DepthTest:
if (enabled) GL.Enable(EnableCap.DepthTest);
else GL.Disable(EnableCap.DepthTest);
GLHelpers.CheckErrors(GL);
break;
case RenderState.ScissorTest:
if (enabled) GL.Enable(EnableCap.ScissorTest);
else GL.Disable(EnableCap.ScissorTest);
GLHelpers.CheckErrors(GL);
break;
case RenderState.DepthWrite:
if (enabled) GL.DepthMask(true);
else GL.DepthMask(false);
GLHelpers.CheckErrors(GL);
break;
case RenderState.Fog:
break;
case RenderState.Lighting:
break;
}
}
/// <inheritdoc />
protected override void SetBlendFactorInternal(BlendFactor srcBlendFactor, BlendFactor dstBlendFactor) {
GL.BlendFunc(Convert(srcBlendFactor), Convert(dstBlendFactor));
GLHelpers.CheckErrors(GL);
}
protected override void SetScissorRectInternal(Rectangle scissor) {
var gtop = (int)Viewport.Height - scissor.Y - scissor.Height;
GL.Scissor(scissor.X, gtop, (uint)scissor.Width, (uint)scissor.Height);
GLHelpers.CheckErrors(GL);
}
protected override void SetViewportInternal(Rectangle viewport) {
GL.Viewport(viewport.X, viewport.Y, (uint)viewport.Width, (uint)viewport.Height);
GLHelpers.CheckErrors(GL);
}
protected override void SetPolygonModeInternal(Chorizite.Core.Render.Enums.PolygonMode polygonMode) {
GL.PolygonMode(GLEnum.FrontAndBack, Convert(polygonMode));
GLHelpers.CheckErrors(GL);
}
protected override void SetCullModeInternal(CullMode cullMode) {
switch (cullMode) {
case CullMode.None:
GL.Disable(EnableCap.CullFace);
break;
case CullMode.Front:
GL.Enable(EnableCap.CullFace);
GL.CullFace(GLEnum.Front);
break;
case CullMode.Back:
GL.Enable(EnableCap.CullFace);
GL.CullFace(GLEnum.Back);
break;
}
}
private GLEnum Convert(Chorizite.Core.Render.Enums.PolygonMode mode) {
switch (mode) {
case Chorizite.Core.Render.Enums.PolygonMode.Fill:
return GLEnum.Fill;
case Chorizite.Core.Render.Enums.PolygonMode.Line:
return GLEnum.Line;
case Chorizite.Core.Render.Enums.PolygonMode.Point:
return GLEnum.Point;
default:
return GLEnum.Fill;
}
}
private GLEnum Convert(ClearFlags flags) {
GLEnum mask = 0;
if ((flags & ClearFlags.Color) == ClearFlags.Color) mask |= GLEnum.ColorBufferBit;
if ((flags & ClearFlags.Depth) == ClearFlags.Depth) mask |= GLEnum.DepthBufferBit;
if ((flags & ClearFlags.Stencil) == ClearFlags.Stencil) mask |= GLEnum.StencilBufferBit;
return mask;
}
private GLEnum Convert(BlendFactor factor) {
switch (factor) {
case BlendFactor.One:
return GLEnum.One;
case BlendFactor.SrcAlpha:
return GLEnum.SrcAlpha;
case BlendFactor.OneMinusSrcAlpha:
return GLEnum.OneMinusSrcAlpha;
case BlendFactor.DstAlpha:
return GLEnum.DstAlpha;
case BlendFactor.OneMinusDstAlpha:
return GLEnum.OneMinusDstAlpha;
default:
return GLEnum.One;
}
}
private PrimitiveType Convert(Chorizite.Core.Render.Enums.PrimitiveType type) {
switch (type) {
case Chorizite.Core.Render.Enums.PrimitiveType.PointList:
return PrimitiveType.Points;
case Chorizite.Core.Render.Enums.PrimitiveType.LineList:
return PrimitiveType.Lines;
case Chorizite.Core.Render.Enums.PrimitiveType.LineStrip:
return PrimitiveType.LineStrip;
case Chorizite.Core.Render.Enums.PrimitiveType.TriangleList:
return PrimitiveType.Triangles;
case Chorizite.Core.Render.Enums.PrimitiveType.TriangleStrip:
return PrimitiveType.TriangleStrip;
default:
throw new NotImplementedException($"Primitive type {type} is not supported.");
}
}
/// <inheritdoc />
public override IFramebuffer CreateFramebuffer(ITexture texture, int width, int height,
bool hasDepthStencil = true) {
if (texture == null) {
throw new ArgumentNullException(nameof(texture));
}
if (width <= 0 || height <= 0) {
throw new ArgumentException("Width and height must be positive.");
}
return new ManagedGLFramebuffer(this, texture, width, height, hasDepthStencil);
}
/// <inheritdoc />
public override void BindFramebuffer(IFramebuffer? framebuffer) {
uint fboId = framebuffer != null ? (uint)framebuffer.NativeHandle.ToInt32() : 0;
GL.BindFramebuffer(FramebufferTarget.Framebuffer, fboId);
}
/// <inheritdoc />
public override void Dispose() {
var instanceVBO = InstanceVBO;
var instanceBufferCapacity = _instanceBufferCapacity;
var instanceBufferStride = _instanceBufferStride;
var wrapSampler = WrapSampler;
var clampSampler = ClampSampler;
var sharedQuadVbo = SharedQuadVBO;
var sharedDebugInstanceVbo = SharedDebugInstanceVBO;
var sharedDebugVao = SharedDebugVAO;
QueueGLAction(gl => {
if (sharedQuadVbo != 0) gl.DeleteBuffer(sharedQuadVbo);
if (sharedDebugInstanceVbo != 0) gl.DeleteBuffer(sharedDebugInstanceVbo);
if (sharedDebugVao != 0) gl.DeleteVertexArray(sharedDebugVao);
if (instanceVBO != 0) {
gl.DeleteBuffer(instanceVBO);
if (instanceBufferCapacity > 0) {
GpuMemoryTracker.TrackDeallocation(instanceBufferCapacity * instanceBufferStride);
}
}
});
// Bindless texture-array retirements embed these samplers in their
// resident handles. Ordinary GL work must keep flowing when one
// retry is sick, but sampler deletion itself is dependency-ordered
// behind the retry queue. Requeue into the next generation (never
// the drain-to-empty ordinary queue) to remain one attempt/frame.
Action<GL>? deleteSamplersWhenSafe = null;
deleteSamplersWhenSafe = gl => {
if (!_nextGlThreadQueue.IsEmpty) {
QueueGLActionForNextPass(deleteSamplersWhenSafe!);
return;
}
if (wrapSampler != 0)
gl.DeleteSampler(wrapSampler);
if (clampSampler != 0)
gl.DeleteSampler(clampSampler);
};
QueueGLActionForNextPass(deleteSamplersWhenSafe);
InstanceVBO = 0;
InstanceVBOPtr = null;
WrapSampler = 0;
ClampSampler = 0;
_sceneDataBuffer?.Dispose();
_sceneDataBuffer = null;
}
public override IUniformBuffer CreateUniformBuffer(BufferUsage usage, int size) {
return (IUniformBuffer)new ManagedGLUniformBuffer(this, usage, size);
}
}
}

View file

@ -1,26 +0,0 @@
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Tracks currently-bound GL state to skip redundant rebinds across the
/// WB-derived render path. Previously these were static fields on
/// <c>BaseObjectRenderManager</c> in the WorldBuilder.Shared project; inlined
/// here in Phase O-T7 to eliminate the WorldBuilder project reference.
///
/// Semantics are identical to the WB originals:
/// <c>CurrentAtlas</c> — slot index of the currently bound texture atlas.
/// <c>CurrentVAO</c> — OpenGL name of the currently bound vertex array object.
/// <c>CurrentIBO</c> — OpenGL name of the currently bound index buffer object.
/// Sentinel value 0 means "no valid binding cached."
/// </summary>
/// <remarks>
/// All accesses must occur on the render thread. GL state binding is
/// not thread-safe; these sentinels are written immediately after the
/// corresponding glBind* call and read by the next dispatch on the
/// same thread.
/// </remarks>
public static class RenderStateCache
{
public static uint CurrentAtlas = 0;
public static uint CurrentVAO = 0;
public static uint CurrentIBO = 0;
}

View file

@ -1,283 +0,0 @@
using Silk.NET.OpenGL;
using AcDream.App.Rendering;
using System.Runtime.ExceptionServices;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Always-on transaction boundary and accounting for raw dynamic GL objects.
/// Growth keeps the published CPU capacity unchanged until BufferData succeeds;
/// OpenGL leaves the previous data store intact when allocation reports an
/// error, so the caller can continue using the old capacity or unwind.
/// </summary>
internal static unsafe class TrackedGlResource
{
public static RetryableGpuResourceRelease CreateRetryableBufferDeletion(
GL gl,
uint buffer,
long capacityBytes,
string context) =>
CreateRetryableBufferDeletion(
gl,
buffer,
() => capacityBytes,
context);
public static RetryableGpuResourceRelease CreateRetryableBufferDeletion(
GL gl,
uint buffer,
Func<long> capacityBytes,
string context)
{
if (buffer == 0)
throw new ArgumentOutOfRangeException(nameof(buffer));
ArgumentNullException.ThrowIfNull(capacityBytes);
return new RetryableGpuResourceRelease(
() => GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"),
() =>
{
gl.DeleteBuffer(buffer);
// Per the GL error contract, a command which generates an
// error does not change object state. Keep mutation and its
// validation in one retryable stage so that failed deletion
// is issued again, while later accounting remains untouched.
GLHelpers.ThrowOnResourceError(gl, context);
},
() =>
{
long bytes = capacityBytes();
ArgumentOutOfRangeException.ThrowIfNegative(bytes);
if (bytes != 0)
GpuMemoryTracker.TrackDeallocation(bytes, GpuResourceType.Buffer);
},
() => GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer));
}
public static RetryableGpuResourceRelease CreateRetryableVertexArrayDeletion(
GL gl,
uint vertexArray,
string context,
Action? trackDeallocation = null)
{
if (vertexArray == 0)
throw new ArgumentOutOfRangeException(nameof(vertexArray));
return new RetryableGpuResourceRelease(
() => GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"),
() =>
{
gl.DeleteVertexArray(vertexArray);
GLHelpers.ThrowOnResourceError(gl, context);
},
trackDeallocation
?? (() => GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.VAO)));
}
public static void AllocateBufferStorage(
GL gl,
BufferTargetARB target,
uint buffer,
long previousBytes,
long newBytes,
BufferUsageARB usage,
string context)
=> AllocateBufferStorage(
gl,
(GLEnum)target,
buffer,
previousBytes,
newBytes,
(GLEnum)usage,
context);
public static void AllocateBufferStorage(
GL gl,
BufferTargetARB target,
uint buffer,
long previousBytes,
long newBytes,
BufferUsageARB usage,
void* data,
string context)
=> AllocateBufferStorage(
gl,
(GLEnum)target,
buffer,
previousBytes,
newBytes,
(GLEnum)usage,
data,
context);
public static uint CreateBuffer(GL gl, string context)
{
return CreateTrackedName(
gl,
"buffer",
context,
gl.GenBuffer,
name => GlResourceCommand.DeleteBuffer(
gl,
name,
$"rollback buffer {name} after failed {context}"),
() => GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer));
}
public static uint CreateVertexArray(GL gl, string context)
{
return CreateTrackedName(
gl,
"vertex-array",
context,
gl.GenVertexArray,
name => GlResourceCommand.DeleteVertexArray(
gl,
name,
$"rollback vertex array {name} after failed {context}"),
() => GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.VAO));
}
private static uint CreateTrackedName(
GL gl,
string resourceName,
string context,
Func<uint> create,
Action<uint> rollback,
Action publishAccounting)
{
uint name = GlResourceCommand.CreateName(
gl,
$"{resourceName} for {context}",
create,
rollback);
var cleanup = new ResourceCleanupGroup();
cleanup.Add($"{resourceName} name {name}", () => rollback(name));
try
{
publishAccounting();
return name;
}
catch (Exception publicationFailure)
{
try
{
cleanup.RetryCleanup();
}
catch (Exception cleanupFailure)
{
throw new GlResourceConstructionException(
$"Publishing {resourceName} accounting failed and GL name {name} could not be released.",
cleanup,
[publicationFailure, cleanupFailure]);
}
ExceptionDispatchInfo.Capture(publicationFailure).Throw();
throw new InvalidOperationException("Unreachable resource-publication path.");
}
}
public static void AllocateBufferStorage(
GL gl,
GLEnum target,
uint buffer,
long previousBytes,
long newBytes,
GLEnum usage,
string context)
=> AllocateBufferStorage(
gl,
target,
buffer,
previousBytes,
newBytes,
usage,
null,
context);
public static void AllocateBufferStorage(
GL gl,
GLEnum target,
uint buffer,
long previousBytes,
long newBytes,
GLEnum usage,
void* data,
string context)
{
ArgumentOutOfRangeException.ThrowIfNegative(previousBytes);
ArgumentOutOfRangeException.ThrowIfLessThan(newBytes, 1);
if (buffer == 0)
throw new ArgumentOutOfRangeException(nameof(buffer));
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
gl.BindBuffer(target, buffer);
gl.BufferData(target, checked((nuint)newBytes), data, usage);
GLHelpers.ThrowOnResourceError(gl, context);
long delta = checked(newBytes - previousBytes);
if (delta > 0)
GpuMemoryTracker.TrackAllocation(delta, GpuResourceType.Buffer);
else if (delta < 0)
GpuMemoryTracker.TrackDeallocation(-delta, GpuResourceType.Buffer);
}
public static void UpdateBufferSubData(
GL gl,
BufferTargetARB target,
uint buffer,
nint byteOffset,
long byteCount,
void* data,
string context)
=> UpdateBufferSubData(
gl,
(GLEnum)target,
buffer,
byteOffset,
byteCount,
data,
context);
public static void UpdateBufferSubData(
GL gl,
GLEnum target,
uint buffer,
nint byteOffset,
long byteCount,
void* data,
string context)
{
ArgumentOutOfRangeException.ThrowIfNegative(byteOffset);
ArgumentOutOfRangeException.ThrowIfLessThan(byteCount, 1);
if (buffer == 0)
throw new ArgumentOutOfRangeException(nameof(buffer));
ArgumentNullException.ThrowIfNull(data);
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
gl.BindBuffer(target, buffer);
gl.BufferSubData(target, byteOffset, checked((nuint)byteCount), data);
GLHelpers.ThrowOnResourceError(gl, context);
}
public static void DeleteBuffer(GL gl, uint buffer, long capacityBytes, string context)
{
if (buffer == 0)
return;
ArgumentOutOfRangeException.ThrowIfNegative(capacityBytes);
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
gl.DeleteBuffer(buffer);
GLHelpers.ThrowOnResourceError(gl, context);
if (capacityBytes != 0)
GpuMemoryTracker.TrackDeallocation(capacityBytes, GpuResourceType.Buffer);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer);
}
public static void DeleteVertexArray(GL gl, uint vertexArray, string context)
{
if (vertexArray == 0)
return;
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
gl.DeleteVertexArray(vertexArray);
GLHelpers.ThrowOnResourceError(gl, context);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.VAO);
}
}

File diff suppressed because it is too large Load diff

View file

@ -200,39 +200,15 @@ public sealed class WbMeshAdapter
{
// Campaign V slice V6i-3: WHICH mesh-pipeline device is decided
// here, once, and it is the only place in the mesh pipeline that
// names a backend. The GL arm is unchanged — same construction,
// same queue-drain guarantee on rollback. A backend with no context
// gets the RHI arm, whose queue is empty by construction because
// Vulkan resource work is recorded or retirement-queued rather than
// deferred onto a context-owning thread.
if (gl is { } context)
{
var openGl = new OpenGLGraphicsDevice(
context,
logger,
new DebugRenderSettings(),
resourceRetirement);
graphicsDevice = openGl;
var graphicsDeviceRelease = new RetryableGpuResourceRelease(
openGl.Dispose,
() =>
{
openGl.ProcessGLQueue();
if (openGl.HasPendingGLWork)
{
throw new InvalidOperationException(
"WB graphics-device construction cleanup still has queued GL work.");
}
});
resources.Add("WB graphics device", graphicsDeviceRelease.Run);
}
else
{
var rhiDevice = new AcDream.App.Rendering.Gpu.Vk.VulkanMeshPipelineDevice(
resourceRetirement);
graphicsDevice = rhiDevice;
resources.Add("WB graphics device", rhiDevice.Dispose);
}
// names a backend. The raw-GL arm (OpenGLGraphicsDevice) was
// deleted at Campaign V slice V11; the RHI arm's queue is empty by
// construction because Vulkan resource work is recorded or
// retirement-queued rather than deferred onto a context-owning
// thread.
var rhiDevice = new AcDream.App.Rendering.Gpu.Vk.VulkanMeshPipelineDevice(
resourceRetirement);
graphicsDevice = rhiDevice;
resources.Add("WB graphics device", rhiDevice.Dispose);
if (resolvedPreparedAssets is null)
{
resolvedPreparedAssets = new DatPreparedAssetSource(
@ -267,19 +243,6 @@ public sealed class WbMeshAdapter
: null;
}
/// <summary>
/// Campaign V slice V4t: the GL device whose texture table every mesh
/// batch's <c>GpuTextureSlot</c> indexes. The world renderers this adapter
/// feeds flush and bind that table before their raw-GL draws, and taking it
/// from here rather than from a second composition wire is what makes "the
/// slot and the table came from the same device" true by construction.
/// </summary>
internal AcDream.App.Rendering.Gpu.Gl.GlGpuDevice WorldTextureTable =>
(_meshManager
?? throw new InvalidOperationException(
"An initialized mesh adapter is required for the world texture table."))
.WorldTextureTable;
internal void RegisterResidencySources(ResidencyManager manager)
{
ArgumentNullException.ThrowIfNull(manager);

View file

@ -1,6 +1,6 @@
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Gl;
using AcDream.App.Rendering.Gpu.Vk;
using AcDream.Core.Rendering.Wb;
using Chorizite.Core.Render.Enums;
using Microsoft.Extensions.Logging;
using Silk.NET.OpenGL;
@ -15,21 +15,21 @@ namespace AcDream.App.Rendering.Wb;
/// deliberately left CREATION with the caches — plan §5.5.11 records why, and
/// §5.5.12 item 1 hands the remainder forward: "the missing piece is an
/// <c>ITextureArray</c> implementation over <see cref="IGpuTexture"/>, not a
/// codec." This is that interface. <see cref="ManagedGLTextureArray"/> and
/// <see cref="RhiWorldTextureArray"/> implement it, and which one exists is
/// decided once at composition by <see cref="IWorldTextureArrayFactory"/> —
/// never per call, so the GL path executes exactly the statements it executed
/// before.</para>
/// codec." This is that interface. <c>ManagedGLTextureArray</c> used to be its
/// GL implementation, alongside <see cref="RhiWorldTextureArray"/>; which one
/// existed was decided once at composition by
/// <see cref="IWorldTextureArrayFactory"/>, never per call. Campaign V slice
/// V11 deleted <c>ManagedGLTextureArray</c> along with the rest of the raw-GL
/// arm, so <see cref="RhiWorldTextureArray"/> is now the sole implementation.</para>
///
/// <para><b>The slot, not the handle, is the seam.</b> Before this slice
/// <para><b>The slot, not the handle, is the seam.</b> Before V6i-2
/// <c>ObjectMeshManager</c> read <c>BindlessWrapHandle</c>/
/// <c>BindlessClampHandle</c> off the concrete GL array and interned them into
/// the device table itself. A 64-bit <c>ARB_bindless_texture</c> handle is
/// unspellable on Vulkan, so the array now answers the question the caller was
/// really asking — <see cref="ResolveSlot"/> — and each implementation gets
/// there its own way: the GL array interns its resident handle (the same
/// idempotent call, one level down), while the RHI array registered its two
/// (texture, sampler) pairs at construction and returns a field.</para>
/// unspellable on Vulkan, so the array answers the question the caller was
/// really asking — <see cref="ResolveSlot"/> — instead: the RHI array
/// registered its two (texture, sampler) pairs at construction and returns a
/// field.</para>
/// </summary>
internal interface IWorldTextureArray : IDisposable
{
@ -116,9 +116,9 @@ internal interface IWorldTextureArrayFactory
ArgumentNullException.ThrowIfNull(graphicsDevice);
ArgumentNullException.ThrowIfNull(gpuDevice);
ArgumentNullException.ThrowIfNull(logger);
return graphicsDevice is OpenGLGraphicsDevice gl && gpuDevice is GlGpuDevice table
? new GlWorldTextureArrayFactory(gl, table, logger)
: new RhiWorldTextureArrayFactory(gpuDevice);
// The GL arm this used to select between was deleted at Campaign V
// slice V11; the RHI arm is the only one left.
return new RhiWorldTextureArrayFactory(gpuDevice);
}
/// <summary>The retirement queue array layers and images are released through.</summary>
@ -132,36 +132,6 @@ internal interface IWorldTextureArrayFactory
IWorldTextureArray CreateClampedArray(TextureFormat format, int width, int height, int layers);
}
/// <summary>
/// The GL arm. Delegates to the same <c>OpenGLGraphicsDevice</c> entry point
/// <see cref="TextureAtlasManager"/> called directly before this slice, so the
/// shipping backend's construction is textually unchanged.
/// </summary>
internal sealed class GlWorldTextureArrayFactory(
OpenGLGraphicsDevice graphicsDevice,
GlGpuDevice worldTextureTable,
ILogger logger) : IWorldTextureArrayFactory
{
private readonly OpenGLGraphicsDevice _graphicsDevice = graphicsDevice
?? throw new ArgumentNullException(nameof(graphicsDevice));
private readonly GlGpuDevice _worldTextureTable = worldTextureTable
?? throw new ArgumentNullException(nameof(worldTextureTable));
private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger));
public IGpuResourceRetirementQueue Retirement => _graphicsDevice.ResourceRetirement;
public IWorldTextureArray CreateClampedArray(TextureFormat format, int width, int height, int layers) =>
new ManagedGLTextureArray(
_graphicsDevice,
format,
width,
height,
layers,
_logger,
_worldTextureTable,
TextureParameters.ClampToEdge);
}
/// <summary>
/// The backend-neutral arm. Creates through <see cref="IGpuDevice.CreateTexture"/>
/// and registers both address modes into the device's one texture table, so an
@ -353,11 +323,7 @@ internal sealed class RhiWorldTextureArray : IWorldTextureArray
ArgumentNullException.ThrowIfNull(data);
ArgumentOutOfRangeException.ThrowIfNegative(layer);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(layer, Size);
// The GL array validates the payload against the format's expected byte
// count and rejects transfer overrides that contradict it. Reusing that
// validator rather than writing a second one keeps the two arms agreeing
// on what a well-formed layer is.
ManagedGLTextureArray.ValidateUploadPayload(
ValidateUploadPayload(
SourceFormat,
_width,
_height,
@ -504,4 +470,65 @@ internal sealed class RhiWorldTextureArray : IWorldTextureArray
+ "and is not part of GpuTextureDescription. Campaign V's world-draw slice owns "
+ "extending the contract or proving no such atlas exists."),
};
private static bool IsCompressedFormat(TextureFormat format) =>
format is TextureFormat.DXT1 or TextureFormat.DXT3 or TextureFormat.DXT5;
/// <summary>
/// The expected byte count for one uploaded layer of <paramref name="format"/>
/// at <paramref name="width"/>x<paramref name="height"/>.
/// </summary>
internal static int CalculateExpectedDataSize(TextureFormat format, int width, int height)
{
if (IsCompressedFormat(format))
return TextureHelpers.GetCompressedLayerSize(width, height, format);
return format switch
{
TextureFormat.RGBA8 => checked(width * height * 4),
TextureFormat.RGB8 => checked(width * height * 3),
TextureFormat.A8 => checked(width * height),
TextureFormat.Rgba32f => checked(width * height * 16),
_ => throw new NotSupportedException($"Unsupported format {format}"),
};
}
/// <summary>
/// Validates an upload payload against the format's expected byte count and
/// rejects transfer overrides that contradict it.
/// </summary>
internal static void ValidateUploadPayload(
TextureFormat format,
int width,
int height,
int dataLength,
PixelFormat? uploadPixelFormat,
PixelType? uploadPixelType)
{
int expectedBytes = CalculateExpectedDataSize(format, width, height);
if (dataLength != expectedBytes)
{
throw new ArgumentException(
$"Texture-array layer payload has {dataLength} bytes; expected exactly {expectedBytes} "
+ $"for {format} {width}x{height}.",
nameof(dataLength));
}
if (IsCompressedFormat(format))
{
if (uploadPixelFormat.HasValue || uploadPixelType.HasValue)
throw new ArgumentException("Compressed texture uploads cannot specify pixel format/type overrides.");
return;
}
PixelFormat expectedFormat = format.ToPixelFormat();
PixelType expectedType = format.ToPixelType();
if ((uploadPixelFormat ?? expectedFormat) != expectedFormat
|| (uploadPixelType ?? expectedType) != expectedType)
{
throw new ArgumentException(
$"Upload descriptor {uploadPixelFormat}/{uploadPixelType} does not match "
+ $"the {expectedFormat}/{expectedType} transfer required by {format}.");
}
}
}

View file

@ -1,10 +1,20 @@
using System.Numerics;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Wb;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Restores the frame-global rendering convention shared by frame clear and
/// exceptional world-pass rollback. The raw-GL implementation this contract
/// used to have alongside it (<c>RenderFrameGlStateController</c>) was deleted
/// at Campaign V slice V11; <see cref="NullRenderFrameGlState"/> is the only
/// implementation left.
/// </summary>
internal interface IRenderFrameGlState
{
void RestoreFrameDefaults();
}
/// <summary>
/// Campaign V slice V6j: the small graphics surface the two world pass executors
/// touch directly, expressed once so their ordering logic — which is retail's,
@ -75,97 +85,11 @@ internal interface IWorldPassSurface
}
/// <summary>
/// The GL arm. Every statement below is the one the executor used to issue
/// inline, in the same order, against the same objects.
/// </summary>
internal sealed class GlWorldPassSurface : IWorldPassSurface
{
private readonly GL _gl;
private readonly ClipFrame _clipFrame;
private readonly IRetailPViewFramebufferSource _framebuffer;
private readonly WbDrawDispatcher _entities;
private readonly EnvCellRenderer _envCells;
private readonly TerrainModernRenderer? _terrain;
public GlWorldPassSurface(
GL gl,
ClipFrame clipFrame,
IRetailPViewFramebufferSource framebuffer,
WbDrawDispatcher entities,
EnvCellRenderer envCells,
TerrainModernRenderer? terrain)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_clipFrame = clipFrame ?? throw new ArgumentNullException(nameof(clipFrame));
_framebuffer = framebuffer ?? throw new ArgumentNullException(nameof(framebuffer));
_entities = entities ?? throw new ArgumentNullException(nameof(entities));
_envCells = envCells ?? throw new ArgumentNullException(nameof(envCells));
_terrain = terrain;
}
public void PrepareClipFrame(int terrainUploadCount)
{
// Allocate every terrain record before issuing the first draw. BufferData
// must not replace the arena while an earlier slice can reference it.
_clipFrame.ReserveTerrainUploads(_gl, terrainUploadCount);
_clipFrame.UploadRegions(_gl);
_entities.SetClipRegionSsbo(_clipFrame.RegionSsbo);
_envCells.SetClipRegionSsbo(_clipFrame.RegionSsbo);
UploadTerrainClip();
}
public void SetTerrainClip(ReadOnlySpan<Vector4> planes)
{
_clipFrame.SetTerrainClip(planes);
UploadTerrainClip();
}
public void BindTerrainClip() => _clipFrame.BindTerrainClip(_gl);
public void EnableClipDistances()
{
for (int index = 0; index < ClipFrame.MaxPlanes; index++)
_gl.Enable(EnableCap.ClipDistance0 + index);
}
public void DisableClipDistances()
{
for (int index = 0; index < ClipFrame.MaxPlanes; index++)
_gl.Disable(EnableCap.ClipDistance0 + index);
}
public bool BeginScissor(Vector4 ndcAabb)
{
RetailPViewFramebufferSize framebuffer = _framebuffer.Capture();
var box = NdcScissorRect.ToPixels(
ndcAabb,
framebuffer.Width,
framebuffer.Height);
_gl.Enable(EnableCap.ScissorTest);
_gl.Scissor(box.X, box.Y, (uint)box.Width, (uint)box.Height);
return true;
}
public void EndScissor() => _gl.Disable(EnableCap.ScissorTest);
public void ClearInteriorDepth()
{
_gl.Disable(EnableCap.ScissorTest);
_gl.DepthMask(true);
_gl.Clear(ClearBufferMask.DepthBufferBit);
}
private void UploadTerrainClip()
{
TerrainClipBufferBinding binding = _clipFrame.UploadTerrainClip(_gl);
_terrain?.SetClipUbo(binding);
}
}
/// <summary>
/// The RHI arm. The clip tables become ring sections published on the world pass
/// scope, the scissor becomes dynamic state on the borrowed encoder, and the
/// depth clear becomes a scoped <c>vkCmdClearAttachments</c>.
/// The clip tables are ring sections published on the world pass scope, the
/// scissor is dynamic state on the borrowed encoder, and the depth clear is a
/// scoped <c>vkCmdClearAttachments</c>. The raw-GL implementation this used to
/// sit alongside (<c>GlWorldPassSurface</c>) was deleted at Campaign V slice
/// V11.
/// </summary>
internal sealed class RhiWorldPassSurface : IWorldPassSurface
{

View file

@ -59,7 +59,6 @@ public sealed record RuntimeOptions(
float FogEndMultiplier,
ResidencyBudgetOptions ResidencyBudgets,
StreamingWorkBudgetOptions StreamingWorkBudgets,
RenderBackendKind RenderBackend,
string? VulkanDeviceOverride,
string? VulkanForcedUnsupportedFeature,
bool VulkanCapabilityProbe,
@ -147,10 +146,6 @@ public sealed record RuntimeOptions(
FogEndMultiplier: TryParseFloat(env("ACDREAM_FOG_END_MULT")) ?? 0.95f,
ResidencyBudgets: ResidencyBudgetOptions.Parse(env),
StreamingWorkBudgets: StreamingWorkBudgetOptions.Parse(env),
// Campaign V slice V10 flipped this. Unset, empty, or any
// unrecognised value now means Vulkan: it is the shipping backend,
// and a typo must never silently start the backend V11 deletes.
RenderBackend: ParseRenderBackend(env("ACDREAM_RENDER_BACKEND")),
// Physical-device override, matched as a decimal index first and then
// as a case-insensitive device-name substring. Recorded verbatim in
// graphical-capabilities-vulkan.json whether or not it matched.
@ -178,29 +173,6 @@ public sealed record RuntimeOptions(
TryParseNonNegativeInt(env("ACDREAM_VULKAN_PROBE_FRAMES")) ?? 0);
}
/// <summary>
/// Startup backend request. Campaign V slice V10 inverted this: Vulkan is
/// the default, and only the explicit escape-hatch tokens <c>gl</c> and
/// <c>opengl</c> select OpenGL. Everything else — unset, <c>vulkan</c>, or a
/// typo — is Vulkan.
/// </summary>
/// <remarks>
/// The polarity of the typo case is deliberate and it flipped with the
/// default. Before V10 an unrecognised token had to land on GL, because
/// Vulkan was dark and a typo must never silently start a backend that
/// cannot draw. After V10 an unrecognised token must land on Vulkan for the
/// same reason read the other way: GL is the retiring backend that slice V11
/// deletes, so a typo must never silently pin a process to it. <c>opengl</c>
/// is honoured alongside <c>gl</c> because the escape hatch exists to be
/// found, and the failure mode of a near-miss spelling here is a process
/// that quietly is not on the backend the operator asked to fall back to.
/// </remarks>
private static RenderBackendKind ParseRenderBackend(string? value)
=> string.Equals(value, "gl", StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, "opengl", StringComparison.OrdinalIgnoreCase)
? RenderBackendKind.Gl
: RenderBackendKind.Vulkan;
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary>
public bool HasLiveCredentials =>
LiveMode && !string.IsNullOrEmpty(LiveUser) && !string.IsNullOrEmpty(LivePass);

View file

@ -3,7 +3,6 @@ using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using Silk.NET.Input;
using Silk.NET.OpenGL;
namespace AcDream.App.UI;

View file

@ -1,4 +1,4 @@
using System.Reflection;
using System.Reflection;
using System.Runtime.CompilerServices;
using AcDream.App.Audio;
using AcDream.App.Composition;
@ -19,7 +19,6 @@ using AcDream.Runtime.Gameplay;
using DatReaderWriter.DBObjs;
using Silk.NET.Input;
using Silk.NET.OpenAL;
using Silk.NET.OpenGL;
namespace AcDream.App.Tests.Composition;
@ -253,7 +252,7 @@ public sealed class ContentEffectsAudioCompositionTests
Poses = new EntityEffectPoseRegistry();
Factory = new Factory();
Publication = new Publication();
Platform = new GameWindowPlatformResult<GameWindowGraphics, IInputContext>(TestGameWindowGraphics.OpenGl, null!);
Platform = new GameWindowPlatformResult<GameWindowGraphics, IInputContext>(TestGameWindowGraphics.Instance, null!);
Host = (HostInputCameraResult)RuntimeHelpers.GetUninitializedObject(
typeof(HostInputCameraResult));
Dependencies = new ContentEffectsAudioDependencies(

View file

@ -1,4 +1,4 @@
using System.Numerics;
using System.Numerics;
using System.Reflection;
using AcDream.App.Composition;
using AcDream.App.Input;
@ -8,7 +8,6 @@ using AcDream.App.Tests.Rendering.Gpu;
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
using Silk.NET.Maths;
using Silk.NET.OpenGL;
namespace AcDream.App.Tests.Composition;
@ -95,7 +94,7 @@ public sealed class HostInputCameraCompositionTests
IKeyboard keyboard = DispatchProxy.Create<IKeyboard, NullDeviceProxy>();
IMouse mouse = DispatchProxy.Create<IMouse, NullDeviceProxy>();
Input = new InputContext(keyboard, mouse);
Platform = new GameWindowPlatformResult<GameWindowGraphics, IInputContext>(TestGameWindowGraphics.OpenGl, Input);
Platform = new GameWindowPlatformResult<GameWindowGraphics, IInputContext>(TestGameWindowGraphics.Instance, Input);
ViewportAspect = new ViewportAspectState();
Framebuffer = new FramebufferResizeController(ViewportAspect);
Capture = new CaptureSource();

View file

@ -1,61 +1,30 @@
using AcDream.App;
using AcDream.App.Composition;
using Silk.NET.Core.Contexts;
using Silk.NET.OpenGL;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu.Vk;
namespace AcDream.App.Tests.Composition;
/// <summary>
/// Campaign V slice V6h: the graphics handle composition tests hand to a phase.
/// The graphics handle composition tests hand to a phase.
///
/// <para>The phases now select their backend arm from
/// <see cref="GameWindowGraphics"/> rather than from a bare <c>GL</c> reference,
/// so a test that means "compose the OpenGL arm" has to say so. The GL instance
/// is never called: every composition test supplies a stub factory that ignores
/// its context argument, and the loader below would fault if anything did — which
/// is the point. It is a token identifying the arm, not a driver.</para>
/// <para>Campaign V slice V11 deleted the raw-GL arm — <c>GameWindowGraphics</c>
/// no longer has a <c>Backend</c> or <c>Gl</c> member to select between, so this
/// is now a single Vulkan-shaped token: a real <see cref="VulkanWorldPassScope"/>
/// (composition phases require one, and it needs no live surface — just a
/// sample count), no live <see cref="VulkanGraphicsContext"/> because tests never
/// dereference it.</para>
/// </summary>
internal sealed class TestGameWindowGraphics : GameWindowGraphics
{
private readonly GL? _gl;
public static TestGameWindowGraphics Instance { get; } = new();
private TestGameWindowGraphics(RenderBackendKind backend, GL? gl)
private TestGameWindowGraphics()
{
Backend = backend;
_gl = gl;
}
/// <summary>Selects the OpenGL arm, with a context token no test dereferences.</summary>
public static TestGameWindowGraphics OpenGl { get; } =
new(RenderBackendKind.Gl, new GL(new UnusableNativeContext()));
/// <summary>Selects the Vulkan arm: no GL context exists.</summary>
public static TestGameWindowGraphics Vulkan { get; } =
new(RenderBackendKind.Vulkan, null);
public override RenderBackendKind Backend { get; }
public override GL? Gl => _gl;
public override IWorldPassScope? WorldPassScope { get; } = new VulkanWorldPassScope(sampleCount: 1);
public override void Dispose()
{
}
private sealed class UnusableNativeContext : INativeContext
{
public nint GetProcAddress(string proc, int? slot = null) =>
throw new InvalidOperationException(
$"A composition test called GL entry point '{proc}'. " +
"Test graphics are a backend token, not a driver.");
public bool TryGetProcAddress(string proc, out nint addr, int? slot = null)
{
addr = 0;
return false;
}
public void Dispose()
{
}
}
}

View file

@ -1,4 +1,4 @@
using System.Collections.Concurrent;
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using AcDream.App.Composition;
using AcDream.App.Rendering;
@ -14,7 +14,6 @@ using AcDream.UI.Abstractions.Settings;
using DatReaderWriter.DBObjs;
using Silk.NET.Input;
using Silk.NET.OpenGL;
using Shader = AcDream.App.Rendering.Shader;
namespace AcDream.App.Tests.Composition;
@ -90,15 +89,12 @@ public sealed class WorldRenderCompositionTests
}
[Theory]
[InlineData("terrain shader", "terrain shader")]
[InlineData("scene lighting", "scene lighting")]
[InlineData("debug lines", "debug lines")]
[InlineData("HUD", "text renderer|debug font")]
[InlineData("terrain", "terrain")]
[InlineData("mesh shader", "mesh shader")]
[InlineData("WB mesh adapter", "WB mesh adapter")]
[InlineData("texture cache", "texture cache")]
[InlineData("sampler cache", "sampler cache")]
public void FailedPublicationRollsBackOnlyItsUnpublishedResourcePrefix(
string publication,
string expectedReleaseOrder)
@ -193,7 +189,7 @@ public sealed class WorldRenderCompositionTests
if (point == _failurePoint)
throw new InvalidOperationException($"fault at {point}");
}).Compose(
new GameWindowPlatformResult<GameWindowGraphics, IInputContext>(TestGameWindowGraphics.OpenGl, null!),
new GameWindowPlatformResult<GameWindowGraphics, IInputContext>(TestGameWindowGraphics.Instance, null!),
Content,
new SettingsDevToolsResult(
QualitySettings.From(QualityPreset.High)));
@ -224,8 +220,6 @@ public sealed class WorldRenderCompositionTests
public ResidencyBudgetOptions? TextureBudgets { get; private set; }
public ResidencyManager? RegisteredResidency { get; private set; }
public void InitializeGlState(GL gl) { }
public WorldRegionData LoadRegion(IDatReaderWriter dats) =>
new(Stub<Region>(), new float[256]);
@ -233,16 +227,6 @@ public sealed class WorldRenderCompositionTests
WorldEnvironmentController environment,
Region region) { }
public BindlessSupport RequireBindless(GL gl, Action<string> log) =>
Stub<BindlessSupport>();
public TerrainAtlas AcquireTerrainAtlas(
IGameRenderResourceLifetime lifetime,
GL gl,
IDatReaderWriter dats,
BindlessSupport bindless) =>
lifetime.AcquireTerrainAtlas(() => Atlas);
/// <summary>
/// Campaign V slice V6i-2: the arm a backend with no GL context takes.
/// Returns the same stub atlas through the same lifetime owner, so the
@ -258,7 +242,7 @@ public sealed class WorldRenderCompositionTests
/// Recorded rather than run: the exercise needs a real
/// <see cref="IGpuDevice"/> to create images through. Its behaviour is
/// covered by <c>RhiWorldTextureArrayTests</c> and by the Vulkan
/// composition-host run — see plan §5.5.13.
/// composition-host run — see plan §5.5.13.
/// </summary>
public void ExerciseBackendNeutralWorldTextures(
IGpuDevice device,
@ -270,12 +254,6 @@ public sealed class WorldRenderCompositionTests
public void SetTerrainAnisotropic(TerrainAtlas atlas, int level) =>
AnisotropicLevel = level;
public Shader CreateTerrainShader(GL gl, string shadersDirectory) =>
Resource<Shader>("terrain shader");
public SceneLightingUboBinding CreateSceneLighting(GL gl) =>
Resource<SceneLightingUboBinding>("scene lighting");
public SceneLightingUboBinding CreateBackendNeutralSceneLighting(
ICurrentGpuFrameSource frameSource,
IWorldPassScope scope) =>
@ -294,15 +272,6 @@ public sealed class WorldRenderCompositionTests
IGpuDevice device, ICurrentGpuFrameSource frameSource, string shadersDirectory) =>
Resource<TextRenderer>("text renderer");
public TerrainModernRenderer CreateTerrain(
GL gl,
BindlessSupport bindless,
Shader shader,
TerrainAtlas atlas,
IGpuDevice gpuDevice,
IGpuResourceRetirementQueue retirement) =>
Resource<TerrainModernRenderer>("terrain");
public TerrainModernRenderer CreateBackendNeutralTerrain(
IGpuDevice gpuDevice,
ICurrentGpuFrameSource frameSource,
@ -323,9 +292,6 @@ public sealed class WorldRenderCompositionTests
Stub<TerrainBlendingContext>(),
new ConcurrentDictionary<uint, SurfaceInfo>());
public Shader CreateMeshShader(GL gl, string shadersDirectory) =>
Resource<Shader>("mesh shader");
public WbMeshAdapter CreateMeshAdapter(
GL? gl,
IGpuDevice device,
@ -339,10 +305,8 @@ public sealed class WorldRenderCompositionTests
}
public TextureCache CreateTextureCache(
GL gl,
IGpuDevice device,
IDatReaderWriter dats,
BindlessSupport bindless,
IGpuResourceRetirementQueue retirement,
string diagnosticsDirectory,
ResidencyBudgetOptions budgets)
@ -362,9 +326,6 @@ public sealed class WorldRenderCompositionTests
RegisteredResidency = manager;
}
public SamplerCache CreateSamplerCache(GL gl) =>
Resource<SamplerCache>("sampler cache");
public void Release(IDisposable resource)
{
Releases.Add(_names[resource]);
@ -385,10 +346,6 @@ public sealed class WorldRenderCompositionTests
public WbMeshAdapter? MeshAdapter { get; private set; }
public TextureCache? TextureCache { get; private set; }
public void PublishBindlessSupport(BindlessSupport value) =>
Fail("bindless");
public void PublishTerrainShader(Shader value) =>
Fail("terrain shader");
public void PublishSceneLighting(SceneLightingUboBinding value) =>
Fail("scene lighting");
public void PublishDebugLines(DebugLineRenderer value) =>
@ -407,7 +364,6 @@ public sealed class WorldRenderCompositionTests
TerrainBlendingContext blending,
ConcurrentDictionary<uint, SurfaceInfo> surfaceCache) =>
Fail("terrain build state");
public void PublishMeshShader(Shader value) => Fail("mesh shader");
public void PublishWbMeshAdapter(WbMeshAdapter value)
{
@ -421,9 +377,6 @@ public sealed class WorldRenderCompositionTests
TextureCache = value;
}
public void PublishSamplerCache(SamplerCache value) =>
Fail("sampler cache");
private void Fail(string point)
{
if (string.Equals(failure, point, StringComparison.Ordinal))

View file

@ -1,261 +0,0 @@
using System.Text.Json;
using AcDream.App.Platform;
namespace AcDream.App.Tests.Platform;
public sealed class GraphicalCapabilityRequirementsTests
{
[Fact]
public void SupportedModernContextPasses()
{
GraphicalCapabilityRecord capabilities = CreateSupported();
Assert.Empty(GraphicalCapabilityRequirements.Evaluate(capabilities));
}
[Theory]
[InlineData("bindless")]
[InlineData("draw-parameters")]
[InlineData("mdi")]
[InlineData("ssbo")]
[InlineData("timer")]
[InlineData("depth")]
[InlineData("stencil")]
[InlineData("srgb")]
[InlineData("keyboard")]
[InlineData("mouse")]
public void MissingMandatoryCapabilityIsRejected(string missing)
{
GraphicalCapabilityRecord capabilities = CreateSupported();
capabilities = missing switch
{
"bindless" => capabilities with
{
HasBindlessTexture = false,
},
"draw-parameters" => capabilities with
{
HasShaderDrawParameters = false,
},
"mdi" => capabilities with
{
HasMultiDrawIndirect = false,
},
"ssbo" => capabilities with
{
HasShaderStorageBuffer = false,
},
"timer" => capabilities with
{
HasTimerQuery = false,
},
"depth" => capabilities with
{
Framebuffer = capabilities.Framebuffer with
{
DepthBits = 16,
},
},
"stencil" => capabilities with
{
Framebuffer = capabilities.Framebuffer with
{
StencilBits = 0,
},
},
"srgb" => capabilities with
{
Framebuffer = capabilities.Framebuffer with
{
FramebufferSrgbApi = false,
},
},
"keyboard" => capabilities with
{
Input = capabilities.Input with
{
KeyboardCount = 0,
},
},
"mouse" => capabilities with
{
Input = capabilities.Input with
{
MouseCount = 0,
},
},
_ => throw new ArgumentOutOfRangeException(nameof(missing)),
};
Assert.NotEmpty(GraphicalCapabilityRequirements.Evaluate(capabilities));
}
[Fact]
public void AdvertisedBufferStorageRequiresSuccessfulPersistentProbe()
{
GraphicalCapabilityRecord capabilities = CreateSupported() with
{
FunctionProbe = CreateSupported().FunctionProbe with
{
PersistentBufferStorage = false,
},
};
Assert.Contains(
GraphicalCapabilityRequirements.Evaluate(capabilities),
failure => failure.Contains(
"persistent mapping",
StringComparison.Ordinal));
}
[Fact]
public void MissingOptionalBufferStorageDoesNotRequireProbe()
{
GraphicalCapabilityRecord capabilities = CreateSupported() with
{
HasBufferStorage = false,
FunctionProbe = CreateSupported().FunctionProbe with
{
PersistentBufferStorage = null,
},
};
Assert.Empty(GraphicalCapabilityRequirements.Evaluate(capabilities));
}
[Fact]
public void UnsupportedMessageNamesDriverProtocolFailureAndReport()
{
GraphicalCapabilityRecord capabilities = CreateSupported() with
{
SupportFailures = ["GL_ARB_bindless_texture is required."],
};
string message = GraphicalCapabilityGuard.FormatUnsupportedMessage(
capabilities,
"capabilities.json");
Assert.Contains("Mesa", message);
Assert.Contains("RadeonSI", message);
Assert.Contains("Wayland", message);
Assert.Contains("GL_ARB_bindless_texture", message);
Assert.Contains(
Path.GetFullPath("capabilities.json"),
message);
}
[Fact]
public void ReportWriterAtomicallyOverwritesJson()
{
string directory = Path.Combine(
Path.GetTempPath(),
"acdream-capability-tests",
Guid.NewGuid().ToString("N"));
string path = Path.Combine(directory, "capabilities.json");
try
{
GraphicalCapabilityReportWriter.Write(path, CreateSupported());
GraphicalCapabilityReportWriter.Write(
path,
CreateSupported() with
{
GlRenderer = "second renderer",
});
using JsonDocument report = JsonDocument.Parse(
File.ReadAllText(path));
Assert.Equal(
"second renderer",
report.RootElement
.GetProperty(nameof(GraphicalCapabilityRecord.GlRenderer))
.GetString());
Assert.Equal(
"Wayland",
report.RootElement
.GetProperty(nameof(
GraphicalCapabilityRecord.ActiveDisplayProtocol))
.GetString());
Assert.False(File.Exists(path + ".tmp"));
}
finally
{
if (Directory.Exists(directory))
Directory.Delete(directory, recursive: true);
}
}
private static GraphicalCapabilityRecord CreateSupported() => new(
DateTimeOffset.UnixEpoch,
"linux-x64",
GraphicalHostOperatingSystem.Linux,
GraphicalDisplayProtocol.Wayland,
GraphicalDisplayProtocol.Wayland,
"test",
"Silk.NET.Windowing.Glfw",
"3.4.0",
"Mesa",
"RadeonSI",
"4.6",
"4.60",
4,
6,
1,
1,
HasBindlessTexture: true,
HasShaderDrawParameters: true,
HasMultiDrawIndirect: true,
HasShaderStorageBuffer: true,
HasBufferStorage: true,
HasTimerQuery: true,
MaximumShaderStorageBufferBindings: 16,
MaximumUniformBufferBindings: 72,
MaximumTextureSize: 16_384,
MaximumArrayTextureLayers: 2_048,
MaximumCombinedTextureImageUnits: 192,
new GraphicalFramebufferCapabilities(
8,
8,
8,
8,
24,
8,
1,
4,
FramebufferSrgbApi: true),
new GraphicalInputCapabilities(
KeyboardCount: 1,
MouseCount: 1,
GamepadCount: 0,
JoystickCount: 0),
new GraphicalWindowCapabilities(
1280,
720,
2560,
1440,
"test monitor",
144,
VSync: false),
new GraphicalAudioCapabilities(
Requested: false,
Available: false,
PlaybackSubmitted: false,
DisposalComplete: true,
Backend: "not requested"),
new GraphicalSmokeLifecycleCapabilities(
OwnedWindowCount: 1,
OwnedGlApiCount: 1,
OwnedInputContextCount: 1,
OwnedAudioEngineCount: 0,
ShutdownComplete: false),
[],
new GraphicalFunctionProbeResult(
BindlessTexture: true,
ShaderDrawParameters: true,
MultiDrawIndirect: true,
ShaderStorageBuffer: true,
TimerQuery: true,
SrgbFramebuffer: true,
PersistentBufferStorage: true,
Failures: []),
SupportFailures: []);
}

View file

@ -1,5 +1,6 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using Xunit;
namespace AcDream.App.Tests.Rendering;
@ -41,7 +42,11 @@ public class ClipFrameLayoutTests
Assert.Equal(8, ClipFrame.MaxPlanes);
Assert.Equal(144, ClipFrame.TerrainUboBytes);
// Binding contract: mesh clip regions on SSBO binding=2, terrain on UBO binding=2.
Assert.Equal(2u, ClipFrame.MeshClipSsboBinding);
// The mesh side's binding index moved off ClipFrame at Campaign V slice
// V11 — the RHI arm addresses it through GpuBindingModel.StorageClipRegions
// instead of a raw GL binding constant (see ClipFrame's BeginFrame doc
// comment); the terrain UBO binding is still genuinely shared, so it stays.
Assert.Equal(2u, GpuBindingModel.StorageClipRegions);
Assert.Equal(2u, ClipFrame.TerrainClipUboBinding);
}

View file

@ -1,130 +0,0 @@
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
public sealed class ClipFrameUploadTests
{
[Theory]
[InlineData(1, 144)]
[InlineData(16, 144)]
[InlineData(64, 192)]
[InlineData(256, 256)]
[InlineData(512, 512)]
public void TerrainArena_RecordStride_RespectsDriverAlignment(
int alignment,
int expectedStride)
{
Assert.Equal(expectedStride, ClipFrameArenaLayout.RecordStride(alignment));
}
[Fact]
public void TerrainArena_AssignsEverySliceAUniqueOrderedRange()
{
const int stride = 256;
Assert.Equal(0, ClipFrameArenaLayout.RecordOffset(0, stride));
Assert.Equal(256, ClipFrameArenaLayout.RecordOffset(1, stride));
Assert.Equal(512, ClipFrameArenaLayout.RecordOffset(2, stride));
Assert.Equal(1280, ClipFrameArenaLayout.RequiredBytes(5, stride));
}
[Fact]
public void UploadState_RegionsOnce_AndTerrainRangesInSubmissionOrder()
{
var state = new ClipFrameUploadState();
state.BeginFrame();
state.ValidateRegionsNotUploaded();
state.MarkRegionsUploaded();
Assert.True(state.RegionsUploaded);
Assert.Throws<InvalidOperationException>(state.ValidateRegionsNotUploaded);
state.ValidateTerrainReservation(4);
state.CommitTerrainReservation(4);
Assert.Equal(new[] { 0, 1, 2, 3 }, new[]
{
state.NextTerrainRecord(),
state.NextTerrainRecord(),
state.NextTerrainRecord(),
state.NextTerrainRecord(),
});
Assert.Equal(4, state.TerrainUploaded);
Assert.Throws<InvalidOperationException>(() => state.NextTerrainRecord());
state.BeginFrame();
Assert.False(state.RegionsUploaded);
Assert.Equal(0, state.TerrainReserved);
Assert.Equal(0, state.TerrainUploaded);
}
[Fact]
public void ResourceRing_RetainsAtMostOneResourcePerFencedFrameSlot()
{
var ring = new ClipFrameResourceRing<object>(ClipFrame.FrameSlotCount);
for (int slot = 0; slot < ClipFrame.FrameSlotCount; slot++)
ring.Set(slot, new object());
// Pathological slice counts reuse ranges in the frame slot's one arena;
// they cannot add another GL object to the ring.
for (int slice = 0; slice < 10_000; slice++)
Assert.True(ring.TryGet(slice % ClipFrame.FrameSlotCount, out _));
Assert.Equal(ClipFrame.FrameSlotCount, ring.Count);
Assert.Throws<InvalidOperationException>(() => ring.Set(0, new object()));
}
[Fact]
public void CapacityPolicy_ShrinksOneOffPathologicalPeakAfterHysteresis()
{
var policy = new ClipBufferCapacityPolicy();
int peak = policy.SelectCapacity(0, 1_000_000);
Assert.True(peak >= 1_000_000);
int first = policy.SelectCapacity(peak, 4_096);
int second = policy.SelectCapacity(first, 4_096);
int third = policy.SelectCapacity(second, 4_096);
Assert.Equal(peak, first);
Assert.Equal(peak, second);
Assert.Equal(4_096, third);
}
[Fact]
public void CapacityPolicy_OrdinaryDemandJitterCancelsPendingShrink()
{
var policy = new ClipBufferCapacityPolicy();
int capacity = policy.SelectCapacity(0, 65_536);
capacity = policy.SelectCapacity(capacity, 4_096);
capacity = policy.SelectCapacity(capacity, 20_000); // above 25% utilization
capacity = policy.SelectCapacity(capacity, 4_096);
capacity = policy.SelectCapacity(capacity, 4_096);
Assert.Equal(65_536, capacity);
}
[Fact]
public void CapacityTransaction_PublishesOnlySuccessfulResize()
{
int capacity = 4_096;
Assert.Throws<InvalidOperationException>(() =>
ClipBufferCapacityTransaction.Resize(
ref capacity,
8_192,
(_, _) => throw new InvalidOperationException("BufferData failed")));
Assert.Equal(4_096, capacity);
ClipBufferCapacityTransaction.Resize(
ref capacity,
8_192,
(previous, next) =>
{
Assert.Equal(4_096, previous);
Assert.Equal(8_192, next);
});
Assert.Equal(8_192, capacity);
// A later stage failure must not roll accounting back to the old store.
Action laterFailure = () => throw new InvalidOperationException("later bind failed");
Assert.Throws<InvalidOperationException>(laterFailure);
Assert.Equal(8_192, capacity);
}
}

View file

@ -138,7 +138,7 @@ public sealed class GameWindowRenderLeafCompositionTests
"new ResourceShutdownStage(\"render frontends\"",
"Hard(\"portal tunnel\"",
"Hard(\"paperdoll viewport\"",
"new ResourceShutdownStage(\"OpenGL context\"");
"new ResourceShutdownStage(\"graphics API context\"");
AssertAppearsInOrder(
source,
"new ResourceShutdownStage(\"frame borrowers\"",
@ -155,7 +155,7 @@ public sealed class GameWindowRenderLeafCompositionTests
"new ResourceShutdownStage(\"render frontends\"",
"new ResourceShutdownStage(\"input context\"",
"platform.Input?.Dispose()",
"new ResourceShutdownStage(\"OpenGL context\"");
"new ResourceShutdownStage(\"graphics API context\"");
}
[Fact]

View file

@ -18,7 +18,10 @@ public sealed class GameWindowSlice8BoundaryTests
"RuntimeSettingsSnapshot startup = _runtimeSettings.Startup",
"_displayFramePacing.InitializeStartup(startup.Display.VSync)",
"VSync = startupPacing.UseVSync",
"Samples = startup.Quality.MsaaSamples",
// Campaign V slice V11: the raw-GL "Samples = ..." window option
// is gone — Vulkan takes MSAA as an RHI attachment property, not
// a window attribute. _startupQuality carries it forward instead.
"_startupQuality = startup.Quality;",
"Window.Create(options)",
"_displayFramePacing.BindSurface(",
"_windowCallbacks = SilkWindowCallbackBinding.Create(",
@ -332,7 +335,13 @@ public sealed class GameWindowSlice8BoundaryTests
run,
"RuntimeSettingsSnapshot startup = _runtimeSettings.Startup",
"_displayFramePacing.InitializeStartup(startup.Display.VSync)",
"Samples = startup.Quality.MsaaSamples",
// Campaign V slice V11: Vulkan needs a client-API-less window and
// takes neither MSAA nor the stencil bit count as a window
// attribute (both are RHI attachment properties instead), so the
// raw-GL "Samples = ..." window option this used to assert is
// gone. _startupQuality carries MsaaSamples forward instead, into
// CreateGraphics' VulkanGraphicsContext.Acquire call.
"_startupQuality = startup.Quality;",
"Window.Create(options)");
AssertAppearsInOrder(
load,
@ -372,9 +381,9 @@ public sealed class GameWindowSlice8BoundaryTests
"WorldRenderComposition.cs"));
AssertAppearsInOrder(
worldPhase,
"TerrainAtlas.Build(gl, dats, bindless)",
"TerrainAtlas.BuildBackendNeutral(device, dats)",
"settings.ResolvedQuality.AnisotropicLevel",
"_factory.CreateTerrain(");
"_factory.CreateBackendNeutralTerrain(");
AssertAppearsInOrder(
shutdown,
"Soft(\"settings view model\", () => ingress.Settings.UnbindViewModel())",
@ -471,7 +480,7 @@ public sealed class GameWindowSlice8BoundaryTests
"new ResourceShutdownStage(\"frame flight owner\"",
"new ResourceShutdownStage(\"content mappings\"",
"new ResourceShutdownStage(\"input context\"",
"new ResourceShutdownStage(\"OpenGL context\"",
"new ResourceShutdownStage(\"graphics API context\"",
];
AssertAppearsInOrder(manifest, stages);
Assert.Equal(stages.Length, CountOccurrences(manifest, "new ResourceShutdownStage("));
@ -563,7 +572,6 @@ public sealed class GameWindowSlice8BoundaryTests
livePhase,
"d.PortalTunnelFallback.AcquirePrepared(",
"static tunnel => tunnel.PrepareResources());",
"d.RenderResourceLifetime.AcquireSkyShader(",
"new SkyRenderer(");
AssertAppearsInOrder(
load,
@ -584,13 +592,14 @@ public sealed class GameWindowSlice8BoundaryTests
AssertAppearsInOrder(
worldPhase,
"lifetime.AcquireTerrainAtlas(",
"TerrainAtlas.Build(gl, dats, bindless)",
"TerrainModernRenderer CreateTerrain(",
"TerrainAtlas.BuildBackendNeutral(device, dats)",
"TerrainModernRenderer CreateBackendNeutralTerrain(",
// Campaign V slice V6j: terrain is composed on both arms, so its
// acquisition is unconditional. The boundary this test pins — that
// the atlas is acquired, then the factory names the renderer, then
// the renderer is acquired AND published in one step — is unchanged.
"TerrainModernRenderer? terrain = AcquireAndPublish(");
// The raw-GL arm (CreateTerrain) was deleted at slice V11.
"TerrainModernRenderer terrain = AcquireAndPublish(");
AssertAppearsInOrder(
shutdown,
"frame.FrameGraphPublication?.Dispose()",
@ -600,7 +609,6 @@ public sealed class GameWindowSlice8BoundaryTests
"render.PortalTunnelFallback.ReleaseFallback();",
"render.Sky?.Dispose()",
"render.Terrain?.Dispose()",
"render.DedicatedResources.ReleaseSkyShader",
"render.DedicatedResources.ReleaseTerrainAtlas",
"render.ConstructionCleanup.Dispose",
"platform.Graphics?.Dispose()");
@ -620,11 +628,11 @@ public sealed class GameWindowSlice8BoundaryTests
AssertAppearsInOrder(
source,
"_window.Run();",
"_glConstructionCleanup.RetainFrom(failure);",
"_constructionCleanup.RetainFrom(failure);",
"private GameWindowShutdownRoots CaptureShutdownRoots()",
"_glConstructionCleanup)");
"_constructionCleanup)");
Assert.Contains(
"Hard(\"GL construction ledger\", render.ConstructionCleanup.Dispose)",
"Hard(\"resource construction ledger\", render.ConstructionCleanup.Dispose)",
shutdown,
StringComparison.Ordinal);
}

Some files were not shown because too many files have changed in this diff Show more