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>
756 lines
37 KiB
C#
756 lines
37 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Linq;
|
||
using System.Numerics;
|
||
using AcDream.Core.Meshing;
|
||
using AcDream.Core.Terrain;
|
||
using AcDream.Core.World;
|
||
using DatReaderWriter;
|
||
using AcDream.Content;
|
||
using DatReaderWriter.DBObjs;
|
||
using DatReaderWriter.Enums;
|
||
|
||
namespace AcDream.App.Rendering.Sky;
|
||
|
||
/// <summary>
|
||
/// Port of <c>references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs</c>.
|
||
/// Draws the retail sky as a stack of independent celestial meshes (the
|
||
/// "it's not a dome" insight from r12 §2) rather than a cube/sphere
|
||
/// with a gradient texture. Each <see cref="SkyObjectData"/> is
|
||
/// visible in a window of day-fraction space, sweeps from
|
||
/// <c>BeginAngle</c> to <c>EndAngle</c> across the sky, and samples its
|
||
/// texture with a per-frame UV scroll driven by <c>TexVelocityX/Y</c>.
|
||
///
|
||
/// <para>
|
||
/// GL state delta per frame:
|
||
/// <list type="bullet">
|
||
/// <item><description>Depth mask OFF, depth test OFF, cull OFF — the sky
|
||
/// should never occlude scene geometry.</description></item>
|
||
/// <item><description>Separate projection matrix with a 0.1–1e6 near/far
|
||
/// so mesh vertices at large distance don't clip.</description></item>
|
||
/// <item><description>View matrix with translation zeroed — sky is
|
||
/// always camera-centred; moving doesn't get you closer to the
|
||
/// sun.</description></item>
|
||
/// </list>
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Meshes are built lazily per GfxObj id on first reference. The
|
||
/// per-object arc transform matches WorldBuilder's composition:
|
||
/// <c>scale × RotZ(-heading) × RotY(-rotation)</c> — the negative signs
|
||
/// come from AC's Z-up right-handed convention where heading is
|
||
/// measured clockwise from north.
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed partial class SkyRenderer : IDisposable
|
||
{
|
||
private readonly IDatReaderWriter _dats;
|
||
private readonly TextureCache _textures;
|
||
|
||
private SkyParams _params;
|
||
|
||
// Lazily-built GPU resources per sky-GfxObj.
|
||
private readonly Dictionary<uint, List<SubMeshGpu>> _gpuByGfxObj = new();
|
||
|
||
// When did we start running — used to accumulate TexVelocityX/Y over
|
||
// real time (independent of the day-fraction clock).
|
||
private readonly DateTime _startedAt = DateTime.UtcNow;
|
||
|
||
/// <summary>
|
||
/// Campaign V slice V7: pins the sky's scroll phase to a fixed number of
|
||
/// seconds instead of reading the wall clock, so two launches agree.
|
||
/// <c>null</c> — the default, and what every ordinary run gets — keeps the
|
||
/// wall clock.
|
||
///
|
||
/// <para><b>Why the sky needs its own pin when the world clock is already
|
||
/// pinnable.</b> Two independent clocks drive this renderer. The Dereth clock
|
||
/// chooses the day group, the keyframe and the sun angle, and
|
||
/// <c>AcdreamCycleTimeOfDay</c> freezes it; the differential route presses it
|
||
/// three times to land on noon. The cloud sheet does not use that clock at
|
||
/// all — <c>TexVelocityX/Y</c> accumulate against real elapsed time, by
|
||
/// design, because retail's clouds drift independently of the date. So a
|
||
/// route that pins the world clock still cannot make two launches agree
|
||
/// about where the clouds are, and the V6m smoke pair measured what that
|
||
/// costs: 89% of an 18.52% whole-frame difference lived in the top 240
|
||
/// rows.</para>
|
||
///
|
||
/// <para><b>This is instrument determinism, not a rendering change.</b> It
|
||
/// alters one input to a UV offset, it is off unless
|
||
/// <c>ACDREAM_SKY_PHASE_SECONDS</c> is set, and nothing in the shipping
|
||
/// client reads it. The alternative — masking the sky band in the gate —
|
||
/// would have permanently blinded the strictest instrument the campaign has
|
||
/// to the whole sky, which is one of the five surfaces the offline gate
|
||
/// already cannot see (plan §5.1).</para>
|
||
/// </summary>
|
||
internal float? AnimationPhaseSecondsOverride { get; init; }
|
||
|
||
// Configurable render distance — retail uses ~1e6; anything larger
|
||
// than the scene far plane works.
|
||
public float Near { get; set; } = 0.1f;
|
||
public float Far { get; set; } = 1_000_000f;
|
||
|
||
/// <summary>
|
||
/// Draw all NON-WEATHER sky objects (dome, sun, moon, stars, clouds —
|
||
/// every <c>SkyObject</c> with <c>Properties & 0x04 == 0</c>).
|
||
/// Called BEFORE the scene; terrain / meshes / debug lines / overlay
|
||
/// land on top via depth-test.
|
||
///
|
||
/// <para>
|
||
/// Mirrors the first half of retail's <c>LScape::draw</c> at
|
||
/// <c>0x00506330</c>: that function calls <c>GameSky::Draw(0)</c>
|
||
/// (sky pass) before the landblock loop, then <c>GameSky::Draw(1)</c>
|
||
/// (weather pass) after. acdream splits the same way — see
|
||
/// <see cref="RenderWeather"/> for the post-scene companion.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Each submesh renders with retail's per-vertex lighting formula:
|
||
/// <c>tint = clamp(emissive + ambient + max(dot(N, -sunDir), 0) * sunColor, 0, 1)</c>
|
||
/// where <c>emissive</c> is the submesh's <c>Surface.Luminosity</c>
|
||
/// float (1.0 for dome + sun + moon → texture passthrough via
|
||
/// saturation; 0.0 for clouds → get the full time-of-day tint).
|
||
/// <paramref name="keyframe"/> supplies the AmbientColor and SunColor
|
||
/// already pre-multiplied by AmbBright / DirBright (loader-side).
|
||
/// </para>
|
||
/// <para>
|
||
/// See <c>docs/research/2026-04-23-sky-retail-verbatim.md</c> §6 for
|
||
/// the full decompile citation. The empirical Dereth dump (
|
||
/// <c>ACDREAM_DUMP_SKY=1</c>, logged 2026-04-23) confirmed the
|
||
/// <c>SurfaceType.Luminous</c> flag bit is NOT set on any Dereth sky
|
||
/// mesh — the differentiator is the <c>Surface.Luminosity</c> FLOAT
|
||
/// field.
|
||
/// </para>
|
||
/// </summary>
|
||
public void RenderSky(
|
||
ICamera camera,
|
||
Vector3 cameraWorldPos,
|
||
float dayFraction,
|
||
DayGroupData? group,
|
||
SkyKeyframe keyframe,
|
||
bool environOverrideActive = false)
|
||
=> RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe,
|
||
postScenePass: false, environOverrideActive: environOverrideActive);
|
||
|
||
/// <summary>
|
||
/// Draw the POST-SCENE sky objects (the foreground rain mesh
|
||
/// <c>0x01004C44</c> on Rainy DayGroups, plus any other SkyObject with
|
||
/// <c>Properties & 0x01 != 0</c>). Called AFTER the scene so these
|
||
/// meshes paint on top of terrain and entities — retail-faithful order
|
||
/// from <c>LScape::draw</c> at <c>0x00506330</c>, where
|
||
/// <c>GameSky::Draw(1)</c> fires after the <c>DrawBlock</c> loop and
|
||
/// renders the <c>after_sky_cell</c> contents. With depth-test
|
||
/// disabled and additive blend (the rain Surface flag includes
|
||
/// Additive), the 815m-tall rain cylinder's bright streak texels add
|
||
/// over the scene — making rain appear in the air between camera and
|
||
/// character instead of only at the horizon.
|
||
/// <para>
|
||
/// Method name kept as <c>RenderWeather</c> for API stability; the
|
||
/// pass actually partitions on <see cref="SkyObjectData.IsPostScene"/>
|
||
/// (Properties bit <c>0x01</c>), not <see cref="SkyObjectData.IsWeather"/>
|
||
/// (bit <c>0x04</c>). The two bits are independent in retail per
|
||
/// <c>GameSky::CreateDeletePhysicsObjects</c> at <c>0x005073c0</c>.
|
||
/// </para>
|
||
/// </summary>
|
||
public void RenderWeather(
|
||
ICamera camera,
|
||
Vector3 cameraWorldPos,
|
||
float dayFraction,
|
||
DayGroupData? group,
|
||
SkyKeyframe keyframe,
|
||
bool environOverrideActive = false)
|
||
=> RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe,
|
||
postScenePass: true, environOverrideActive: environOverrideActive);
|
||
|
||
/// <summary>
|
||
/// Shared pass for <see cref="RenderSky"/> and <see cref="RenderWeather"/>.
|
||
/// Sets up the same GL state for both (depth-test off, additive +
|
||
/// alpha-blend per submesh, camera-anchored translation) and iterates
|
||
/// only the SkyObjects matching the requested partition by
|
||
/// <see cref="SkyObjectData.IsPostScene"/> — bit <c>0x01</c> per the
|
||
/// retail decomp at <c>GameSky::MakeObject</c> (<c>0x00506ee0</c>).
|
||
/// </summary>
|
||
private void RenderPass(
|
||
ICamera camera,
|
||
Vector3 cameraWorldPos,
|
||
float dayFraction,
|
||
DayGroupData? group,
|
||
SkyKeyframe keyframe,
|
||
bool postScenePass,
|
||
bool environOverrideActive)
|
||
{
|
||
if (group is null || group.SkyObjects.Count == 0) return;
|
||
|
||
// Keep sky meshes inside their authored depth range without
|
||
// replacing the active viewport's horizontal/vertical projection.
|
||
// Retail installs SmartBox's active projection before LScape::draw
|
||
// (SmartBox::RenderNormalMode @ 0x00453AA0), then GameSky::Draw
|
||
// (@ 0x00506FF0) changes ONLY zfar to 4x for the sky draw. Preserve
|
||
// that FOV here, including the near-180-degree teleport transition.
|
||
var skyProj = SkyProjection.WithDepthRange(camera.Projection, Near, Far);
|
||
|
||
// View with translation zeroed — keeps the sky at camera origin
|
||
// regardless of camera position in the world.
|
||
var skyView = camera.View;
|
||
skyView.M41 = 0f;
|
||
skyView.M42 = 0f;
|
||
skyView.M43 = 0f;
|
||
|
||
// 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
|
||
// the per-submesh uniforms already had, in one BufferSubData instead of
|
||
// five calls.
|
||
_params.SkyView = skyView;
|
||
_params.SkyProjection = skyProj;
|
||
|
||
// Retail per-vertex lighting inputs (AdjustPlanes formula).
|
||
// AmbColor/SunColor are already × AmbBright/DirBright from
|
||
// SkyDescLoader. SunDir is the unit vector FROM surface TO sun
|
||
// derived from the keyframe's DirHeading/DirPitch.
|
||
_params.AmbientColor = keyframe.AmbientColor;
|
||
_params.SunColor = keyframe.SunColor;
|
||
_params.SunDir =
|
||
AcDream.Core.World.SkyStateProvider.SunDirectionFromKeyframe(keyframe);
|
||
|
||
// 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.
|
||
var replaces = PickReplaces(group, dayFraction);
|
||
|
||
float secondsSinceStart = AnimationPhaseSecondsOverride
|
||
?? (float)(DateTime.UtcNow - _startedAt).TotalSeconds;
|
||
|
||
for (int i = 0; i < group.SkyObjects.Count; i++)
|
||
{
|
||
var obj = group.SkyObjects[i];
|
||
// Partition by post-scene flag (Properties bit 0x01) — the
|
||
// caller chose either the pre-scene sky pass (bit clear) or
|
||
// the post-scene pass (bit set). Mirrors retail
|
||
// GameSky::CreateDeletePhysicsObjects at 0x005073c0 / decomp
|
||
// line 269036 which routes (Properties & 1) into
|
||
// before_sky_cell vs after_sky_cell, and GameSky::Draw at
|
||
// 0x00506ff0 which renders those cells in the two passes.
|
||
// NOTE: bit 0x04 (IsWeather) is independent — it gates whether
|
||
// the object is instantiated when weather_enabled is false.
|
||
// Earlier acdream incorrectly used IsWeather for this
|
||
// partition, putting the outer rain cylinder 0x01004C42
|
||
// (Props=0x04, NO bit 0x01) into the post-scene pass with the
|
||
// foreground rain — double-thick rain not matching retail.
|
||
if (obj.IsPostScene != postScenePass) continue;
|
||
if (!obj.IsVisible(dayFraction)) continue;
|
||
// Retail GameSky::Draw (0x00506ff0) skips Properties bit 0x02
|
||
// objects while an AdminEnvirons fog override is active. Normal
|
||
// DayGroup fog/tint still draws them.
|
||
if (environOverrideActive && (obj.Properties & 0x02u) != 0u)
|
||
continue;
|
||
|
||
// Apply per-keyframe replace overrides.
|
||
uint gfxObjId = obj.GfxObjId;
|
||
float headingDeg = 0f;
|
||
float transparent = 0f;
|
||
// Replace-override luminosity. Stays NaN when there is no
|
||
// replace entry or none of the keyframe's overrides are set,
|
||
// and that NaN is the signal to fall back to the surface's
|
||
// authored Luminosity at draw time. This replaces the previous
|
||
// `luminosity = 1f` default which masked the surface value
|
||
// because the `(luminosity > 0) ? luminosity : sub.SurfLuminosity`
|
||
// fallback at the inner loop never fired (1f is always > 0).
|
||
// RainMeshProbe (committed b8e0857) confirmed empirically that
|
||
// NO Dereth sky surface carries the SurfaceType.Luminous flag
|
||
// bit (0x40) — the differentiator is purely the float field.
|
||
float replaceLuminosity = float.NaN;
|
||
float replaceDiffuse = float.NaN;
|
||
if (replaces.TryGetValue((uint)i, out var rep))
|
||
{
|
||
if (rep.GfxObjId != 0) gfxObjId = rep.GfxObjId;
|
||
if (rep.Rotate != 0f) headingDeg = rep.Rotate;
|
||
transparent = Math.Clamp(rep.Transparent, 0f, 1f);
|
||
if (rep.Luminosity > 0f) replaceLuminosity = rep.Luminosity;
|
||
// Retail GameSky::UseTime routes max_bright through
|
||
// CPhysicsObj::SetDiffusion, so it replaces material diffuse,
|
||
// not emissive/luminosity.
|
||
if (rep.MaxBright > 0f)
|
||
replaceDiffuse = rep.MaxBright;
|
||
}
|
||
if (gfxObjId == 0) continue;
|
||
|
||
// Current arc angle across the sky.
|
||
float rotationDeg = obj.CurrentAngle(dayFraction);
|
||
float headingRad = headingDeg * (MathF.PI / 180f);
|
||
float rotationRad = rotationDeg * (MathF.PI / 180f);
|
||
|
||
// Matches WorldBuilder's composition for a Z-up right-handed
|
||
// frame with heading measured clockwise from north.
|
||
var model = Matrix4x4.CreateScale(1.0f)
|
||
* Matrix4x4.CreateRotationZ(-headingRad)
|
||
* Matrix4x4.CreateRotationY(-rotationRad);
|
||
|
||
// Retail weather Z-offset (GameSky::UpdatePosition at
|
||
// 0x00506dd0, decomp lines 0x506e96..0x506e98):
|
||
//
|
||
// if (((eax_13 & 4) != 0 && (eax_13 & 8) == 0))
|
||
// int32_t var_4_1 = 0xc2f00000; // 0xc2f00000 == -120.0f
|
||
//
|
||
// Gate: bit 0x04 (weather) set AND bit 0x08 unset. NOT every
|
||
// post-scene SkyObject — bit 0x01 (post-scene) is independent
|
||
// of bit 0x04 (weather). Today's Dereth ships every post-scene
|
||
// entry as also weather-flagged so the previous unconditional
|
||
// offset was a no-op divergence, but a future DayGroup with a
|
||
// post-scene-but-not-weather entry (e.g. a foreground sun rim)
|
||
// would have been pushed 120m below the camera and rendered as
|
||
// floor lint.
|
||
//
|
||
// Without the offset on the rain cylinder GfxObjs
|
||
// 0x01004C42/0x01004C44 (local Z range 0.11..814.90) the
|
||
// cylinder bottom sits at z=0.11 ABOVE the camera (skyView
|
||
// translation is zeroed so model-origin == camera); looking
|
||
// horizontally shows nothing. With -120m the cylinder spans z
|
||
// = (camera-119.89)..(camera+694.90) — camera is inside,
|
||
// looking in any direction shows surrounding walls — the
|
||
// volumetric foreground-rain look retail has.
|
||
if (postScenePass && obj.IsWeather && (obj.Properties & 0x08u) == 0u)
|
||
model = model * Matrix4x4.CreateTranslation(0f, 0f, -120f);
|
||
|
||
_params.Model = model;
|
||
|
||
// UV scroll accumulates real-time × velocity. Wrap to [0, 1]
|
||
// so long-running sessions don't accumulate float precision
|
||
// loss in the fragment UV.
|
||
float uOffset = (obj.TexVelocityX * secondsSinceStart) % 1f;
|
||
float vOffset = (obj.TexVelocityY * secondsSinceStart) % 1f;
|
||
_params.UvScroll = new Vector2(uOffset, vOffset);
|
||
_params.Transparency = transparent;
|
||
|
||
EnsureMeshUploaded(gfxObjId);
|
||
if (!_gpuByGfxObj.TryGetValue(gfxObjId, out var subMeshes)) continue;
|
||
|
||
foreach (var sub in subMeshes)
|
||
{
|
||
// Per-submesh blend mode: sun/moon/stars are Additive
|
||
// (SurfaceType.Additive = 0x10000), clouds are AlphaBlend,
|
||
// sky dome is Base1Image (Opaque, mapped to
|
||
// SrcAlpha/InvSrcAlpha for a no-op blend at alpha=1).
|
||
// See FUN_00508010 (chunk_00500000.c:7535) for the retail
|
||
// pattern — retail routes sky meshes through the normal
|
||
// 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.
|
||
// 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
|
||
// retail's FUN_0059da60: surface.Luminosity → D3DMATERIAL.Emissive
|
||
// (via material cache +0x3c), with the keyframe replace
|
||
// promoting bright-keyframe clouds when the keyframe asks.
|
||
//
|
||
// Empirical Dereth sky surfaces (RainMeshProbe, b8e0857):
|
||
// dome/sun/moon → Lum=1.0 → vTint saturates → texture
|
||
// passthrough (correct retail look);
|
||
// stars/clouds → Lum=0.0 → vTint = ambient + diffuse →
|
||
// picks up the time-of-day tint;
|
||
// rain → Lum=0.1484 → faint emissive baseline,
|
||
// ambient+diffuse adds atmospheric tint.
|
||
//
|
||
// Pre-fix: the replace-override variable defaulted to 1f and
|
||
// the fallback `(luminosity > 0) ? luminosity : sub.SurfLuminosity`
|
||
// never fired — every sky mesh got effEmissive=1.0,
|
||
// saturating vTint. That made stars/clouds look full-bright
|
||
// instead of time-of-day-tinted, and made rain streaks
|
||
// 6.7× too bright (one of two factors compounding the
|
||
// foreground-rim visibility bug).
|
||
float effEmissive = float.IsNaN(replaceLuminosity)
|
||
? sub.SurfLuminosity
|
||
: replaceLuminosity;
|
||
float effDiffuse = float.IsNaN(replaceDiffuse)
|
||
? sub.SurfDiffuse
|
||
: replaceDiffuse;
|
||
_params.Emissive = effEmissive;
|
||
_params.DiffuseFactor = effDiffuse;
|
||
|
||
// Material alpha is final opacity: 1 - Surface.Translucency
|
||
// for Translucent surfaces, 1 for non-Translucent surfaces.
|
||
// The CPU computes it once so the shader just multiplies it
|
||
// with texture alpha and keyframe transparency.
|
||
_params.SurfOpacity = sub.SurfOpacity;
|
||
|
||
// Retail D3DPolyRender::SetSurface at 0x59c882 calls
|
||
// SetFFFogAlphaDisabled(1) when the Additive flag (0x10000)
|
||
// is set on the Surface — so the sun, moon, stars, and any
|
||
// additive cloud sheet are drawn WITHOUT fog. Skipping fog
|
||
// on additive surfaces keeps the sun bright at horizon
|
||
// dusk/dawn (where fog would otherwise dim it to fog color).
|
||
// Non-additive sky meshes (the dome/background layers)
|
||
// still mix toward keyframe fog with the floor mitigation
|
||
// in sky.frag. That restores the broad green/purple Rainy
|
||
// DayGroup tint behind the cloud sheet while raw-additive
|
||
// 0x08000023 remains unfogged and keeps the pink detail.
|
||
_params.ApplyFog = sub.DisableFog ? 0f : 1f;
|
||
|
||
// Sky meshes need per-object wrap mode driven by the
|
||
// mesh's authored UV range, not by TexVelocity:
|
||
// * The outer dome (0x010015EE/F0/F1/F2) authors UVs
|
||
// strictly in [0,1]. Under GL_REPEAT the bilinear
|
||
// filter at wall-seam edges would average a texel
|
||
// near the right edge with one near the left edge of
|
||
// the texture, drawing a visible "bleed line" along
|
||
// every dome seam. CLAMP_TO_EDGE avoids that.
|
||
// * The inner sky/star layer (0x010015EF) and the
|
||
// cloud meshes (0x010015B6, 0x01004C36 etc) author
|
||
// UVs that deliberately exceed [0,1] (~0.4..4.6) so
|
||
// the texture tiles across the geometry. CLAMP_TO_EDGE
|
||
// would clamp ~99% of the surface to a single edge
|
||
// texel, leaving only a small "square" where UVs
|
||
// happen to fall in [0,1] (Bug B in
|
||
// docs/research/2026-04-26-sky-investigation-handoff.md).
|
||
// The mesh builder pre-computes NeedsUvRepeat from the
|
||
// actual UV range so the right answer is data-driven.
|
||
// Scrolling clouds are also forced to REPEAT (the running
|
||
// UV offset can drift outside [0,1] regardless of authored
|
||
// range, and they'd show their own seam bleed otherwise).
|
||
//
|
||
// Implementation, before Campaign V slice V6e: bind one of two
|
||
// persistent sampler objects to texture unit 0, because sampler
|
||
// state overrides the texture's own wrap parameters and two
|
||
// renderers can then share a texture but sample it differently.
|
||
// Ported from WorldBuilder
|
||
// (Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs:312).
|
||
//
|
||
// After V6e the same two sampler objects are still what decides
|
||
// the wrap mode — they are just consulted once, when the
|
||
// (texture, wrap) pair is interned as a bindless handle, rather
|
||
// than per draw. A bindless handle carries its sampler with it,
|
||
// so the choice is now WHICH TABLE SLOT this submesh asks for.
|
||
// Same two GL sampler objects, same wrap behaviour, and it is
|
||
// the shape Vulkan's table already has.
|
||
bool needsRepeat = sub.NeedsUvRepeat
|
||
|| obj.TexVelocityX != 0f
|
||
|| obj.TexVelocityY != 0f;
|
||
uint slot = TextureTableSlot(sub.SurfaceId, needsRepeat);
|
||
DrawSubMeshRhi(sub, slot);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Campaign V slice V6e: the table slot for one (texture, wrap-mode) pair,
|
||
/// interning a resident bindless handle on first use.
|
||
///
|
||
/// <para>Two slots per texture is not waste — it is the wrap mode. A
|
||
/// bindless handle bakes its sampler, so the dome sampled CLAMP_TO_EDGE and
|
||
/// a scrolling cloud sheet sampled REPEAT are two different handles even
|
||
/// when they name the same GL texture. Vulkan's table has the same property
|
||
/// for the same reason: an entry there is a combined image sampler.</para>
|
||
///
|
||
/// <para>Entries accumulate for the renderer's lifetime, like every other
|
||
/// 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) =>
|
||
RhiTextureTableSlot(surfaceId, repeat);
|
||
|
||
/// <summary>
|
||
/// Find the <see cref="SkyObjectReplaceData"/> entries for the
|
||
/// keyframe currently "active" at <paramref name="dayFraction"/>.
|
||
/// Matches WorldBuilder's single-keyframe lookup (it picks <c>t1</c>
|
||
/// and doesn't interpolate the replace fields).
|
||
/// </summary>
|
||
private static Dictionary<uint, SkyObjectReplaceData> PickReplaces(
|
||
DayGroupData group, float dayFraction)
|
||
{
|
||
var result = new Dictionary<uint, SkyObjectReplaceData>();
|
||
var times = group.SkyTimes;
|
||
if (times.Count == 0) return result;
|
||
|
||
// Pick k1 = last keyframe with Begin <= dayFraction.
|
||
DatSkyKeyframeData k1 = times[^1];
|
||
for (int i = 0; i < times.Count; i++)
|
||
{
|
||
if (times[i].Keyframe.Begin <= dayFraction)
|
||
k1 = times[i];
|
||
else
|
||
break;
|
||
}
|
||
|
||
foreach (var r in k1.Replaces)
|
||
result[r.ObjectIndex] = r;
|
||
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Lazy mesh build for a sky object. Handles two cases:
|
||
/// <list type="bullet">
|
||
/// <item><description>
|
||
/// <c>0x010xxxxx</c> — direct <see cref="GfxObj"/>. Reuses
|
||
/// <see cref="GfxObjMesh.Build"/> so the pos/neg polygon
|
||
/// splitting logic stays consistent with the main static-mesh
|
||
/// pipeline. Most sky meshes are single-surface.
|
||
/// </description></item>
|
||
/// <item><description>
|
||
/// <c>0x020xxxxx</c> — <see cref="Setup"/>. The agent at
|
||
/// 2026-04-27 found these Setup-backed sky objects (e.g.
|
||
/// <c>0x02000588</c>, <c>0x02000589</c>, <c>0x02000714</c>,
|
||
/// <c>0x02000BA6</c>) were silently dropped: every cache miss
|
||
/// fell into the GfxObj branch, returned null, and got cached
|
||
/// as an empty submesh list. Per the named retail decomp
|
||
/// <c>CPhysicsObj::InitPartArrayObject</c> at <c>0x0050ed40</c>
|
||
/// dispatches type 7 to <c>CPartArray::CreateSetup</c>
|
||
/// (decomp 280484) which loads the Setup and walks its parts.
|
||
/// We mirror that here: <see cref="SetupMesh.Flatten"/> walks
|
||
/// <c>Setup.Parts</c> at the default placement frame and
|
||
/// <see cref="GfxObjMesh.Build"/> produces submeshes for each
|
||
/// part. Per-part transforms are baked into vertex positions
|
||
/// (sky setups are static — no animation needed for the static
|
||
/// mesh half of the visual).
|
||
/// </description></item>
|
||
/// </list>
|
||
/// <para>
|
||
/// Even with this fix the visible aurora-style sheen most retail
|
||
/// rainy/cloudy setups produce comes from the <c>pes_id</c> field
|
||
/// on each <see cref="DatReaderWriter.Types.SkyObject"/> (a Particle
|
||
/// Effect Schedule) — that's a separate Phase-level feature.
|
||
/// Rendering the Setup's static parts here is the geometry half;
|
||
/// the dynamic particle half is deferred.
|
||
/// </para>
|
||
/// </summary>
|
||
private void EnsureMeshUploaded(uint gfxObjId)
|
||
{
|
||
if (_gpuByGfxObj.ContainsKey(gfxObjId)) return;
|
||
|
||
// Setup-backed sky object: walk Setup.Parts and bake per-part
|
||
// transforms into the per-vertex positions. See doc comment above.
|
||
if ((gfxObjId & 0xFF000000u) == 0x02000000u)
|
||
{
|
||
EnsureSetupUploaded(gfxObjId);
|
||
return;
|
||
}
|
||
|
||
// DatCollection isn't thread-safe and the streaming loader can be
|
||
// actively reading a shared DatBinReader buffer; sky meshes are
|
||
// loaded on the render thread but GfxObj.Unpack can race with the
|
||
// streamer. Cache a null entry on any read failure so we don't
|
||
// retry every frame and crash the render loop. A future
|
||
// refactor should move all dat access behind the _datLock.
|
||
GfxObj? gfx = null;
|
||
try { gfx = _dats.Get<GfxObj>(gfxObjId); }
|
||
catch { gfx = null; }
|
||
|
||
if (gfx is null)
|
||
{
|
||
_gpuByGfxObj[gfxObjId] = new List<SubMeshGpu>();
|
||
return;
|
||
}
|
||
|
||
System.Collections.Generic.IReadOnlyList<GfxObjSubMesh>? subMeshes = null;
|
||
try { subMeshes = GfxObjMesh.Build(gfx, _dats); }
|
||
catch { subMeshes = null; }
|
||
|
||
if (subMeshes is null)
|
||
{
|
||
_gpuByGfxObj[gfxObjId] = new List<SubMeshGpu>();
|
||
return;
|
||
}
|
||
|
||
// Phase 1 diagnostic: dump Surface.Type flags on every sky GfxObj
|
||
// once, so we can determine which submeshes carry Luminous (0x40)
|
||
// vs plain-lit. This settles the retail "cloud tint = per-vertex
|
||
// lighting on non-Luminous meshes" hypothesis — see
|
||
// docs/research/2026-04-23-sky-retail-verbatim.md §6.
|
||
if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_SKY") == "1")
|
||
DumpGfxObjSurfaces(gfxObjId, gfx, subMeshes);
|
||
|
||
var gpuList = new List<SubMeshGpu>(subMeshes.Count);
|
||
foreach (var sm in subMeshes)
|
||
gpuList.Add(UploadSubMesh(sm));
|
||
_gpuByGfxObj[gfxObjId] = gpuList;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Setup-backed sky object loader. Walks <see cref="Setup.Parts"/> at
|
||
/// the default placement frame, builds submeshes via
|
||
/// <see cref="GfxObjMesh.Build"/>, and bakes the per-part transform
|
||
/// into the vertex positions before upload. Static-pose only — sky
|
||
/// setups don't animate in any meaningful way for the visual we care
|
||
/// about (the dynamic look comes from <c>pes_id</c> particles, not
|
||
/// the underlying mesh).
|
||
/// <para>
|
||
/// Mirrors retail's <see cref="CPhysicsObj.InitPartArrayObject"/> at
|
||
/// decomp <c>280484</c> dispatching type 7 → <c>CPartArray::CreateSetup</c>
|
||
/// → <c>CSetup::SetSetupID</c>, which loads the setup and instantiates
|
||
/// each part as a separate <c>CPhysicsObj</c> child. We collapse the
|
||
/// children into a flat submesh list because the sky pass renders
|
||
/// without per-part transforms anyway.
|
||
/// </para>
|
||
/// </summary>
|
||
private void EnsureSetupUploaded(uint setupId)
|
||
{
|
||
Setup? setup = null;
|
||
try { setup = _dats.Get<Setup>(setupId); }
|
||
catch { setup = null; }
|
||
|
||
if (setup is null)
|
||
{
|
||
_gpuByGfxObj[setupId] = new List<SubMeshGpu>();
|
||
return;
|
||
}
|
||
|
||
var parts = SetupMesh.Flatten(setup);
|
||
var allSubs = new List<SubMeshGpu>(parts.Count);
|
||
foreach (var partRef in parts)
|
||
{
|
||
GfxObj? partGfx = null;
|
||
try { partGfx = _dats.Get<GfxObj>(partRef.GfxObjId); }
|
||
catch { partGfx = null; }
|
||
if (partGfx is null) continue;
|
||
|
||
System.Collections.Generic.IReadOnlyList<GfxObjSubMesh>? partSubs = null;
|
||
try { partSubs = GfxObjMesh.Build(partGfx, _dats); }
|
||
catch { partSubs = null; }
|
||
if (partSubs is null) continue;
|
||
|
||
// Bake the part's local transform into the vertices. For sky
|
||
// setups we don't expect non-uniform scale, so transforming
|
||
// normals as directions is fine; if a future sky setup ever
|
||
// breaks that assumption we'd need an inverse-transpose here.
|
||
var partTx = partRef.PartTransform;
|
||
foreach (var sub in partSubs)
|
||
{
|
||
var transformed = new Vertex[sub.Vertices.Length];
|
||
for (int i = 0; i < sub.Vertices.Length; i++)
|
||
{
|
||
var v = sub.Vertices[i];
|
||
var p = Vector3.Transform(v.Position, partTx);
|
||
var n = Vector3.Normalize(Vector3.TransformNormal(v.Normal, partTx));
|
||
transformed[i] = v with { Position = p, Normal = n };
|
||
}
|
||
var rebuilt = sub with { Vertices = transformed };
|
||
allSubs.Add(UploadSubMesh(rebuilt));
|
||
}
|
||
}
|
||
_gpuByGfxObj[setupId] = allSubs;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Log each surface's raw flag bits and the derived
|
||
/// <see cref="TranslucencyKind"/>. Called once per GfxObj when
|
||
/// <c>ACDREAM_DUMP_SKY=1</c>. Output format is grep-friendly so
|
||
/// we can pipe the launch log through <c>| grep sky-dump</c> and
|
||
/// recover a complete picture of the Dereth sky without re-running.
|
||
/// </summary>
|
||
private void DumpGfxObjSurfaces(
|
||
uint gfxObjId,
|
||
GfxObj gfx,
|
||
System.Collections.Generic.IReadOnlyList<GfxObjSubMesh> subMeshes)
|
||
{
|
||
Console.WriteLine(
|
||
$"[sky-dump] GfxObj 0x{gfxObjId:X8} Surfaces.Count={gfx.Surfaces.Count} Polygons.Count={gfx.Polygons.Count} SubMeshes.Count={subMeshes.Count}");
|
||
|
||
for (int i = 0; i < gfx.Surfaces.Count; i++)
|
||
{
|
||
uint surfaceId = (uint)gfx.Surfaces[i];
|
||
DatReaderWriter.DBObjs.Surface? surface = null;
|
||
try { surface = _dats.Get<DatReaderWriter.DBObjs.Surface>(surfaceId); }
|
||
catch { surface = null; }
|
||
|
||
if (surface is null)
|
||
{
|
||
Console.WriteLine($"[sky-dump] Surface[{i}] 0x{surfaceId:X8} -- (dat read failed)");
|
||
continue;
|
||
}
|
||
|
||
// SurfaceType is a flag enum — `ToString()` gives the
|
||
// comma-joined names (e.g. "Base1Image, Additive").
|
||
uint rawType = (uint)surface.Type;
|
||
string names = surface.Type.ToString();
|
||
uint origTex = surface.OrigTextureId?.DataId ?? 0u;
|
||
var trans = TranslucencyKindExtensions.FromSurfaceType(surface.Type);
|
||
// Surface's own Luminosity (0..1 fraction per test fixture —
|
||
// different from SkyObjectReplace.Luminosity which lives in the keyframe).
|
||
Console.WriteLine(
|
||
$"[sky-dump] Surface[{i}] 0x{surfaceId:X8} Type=0x{rawType:X8} ({names}) " +
|
||
$"OrigTexture=0x{origTex:X8} Translucency={trans} " +
|
||
$"SurfLuminosity={surface.Luminosity:F4} SurfaceTranslucency={surface.Translucency:F4}");
|
||
}
|
||
}
|
||
|
||
private SubMeshGpu UploadSubMesh(GfxObjSubMesh sm) => UploadSubMeshRhi(sm);
|
||
|
||
public void Dispose() => DisposeRhi();
|
||
|
||
/// <summary>
|
||
/// Campaign V slice V6e: the CPU mirror of sky.{vert,frag}'s <c>SkyParams</c>
|
||
/// std140 block. Sequential layout with 4-byte packing reproduces std140
|
||
/// exactly here because every member is placed so that the float following
|
||
/// each <c>vec3</c> occupies the pad word std140 would insert anyway — which
|
||
/// is why the lighting colours and per-surface scalars interleave rather
|
||
/// than being grouped by meaning. <c>SkyParamsLayoutTests</c> asserts every
|
||
/// offset and the total size, because a silent one-word slip here would
|
||
/// misread the sun direction as a colour and nothing would say so.
|
||
/// </summary>
|
||
[System.Runtime.InteropServices.StructLayout(
|
||
System.Runtime.InteropServices.LayoutKind.Sequential, Pack = 4)]
|
||
internal struct SkyParams
|
||
{
|
||
public Matrix4x4 Model; // 0
|
||
public Matrix4x4 SkyView; // 64
|
||
public Matrix4x4 SkyProjection; // 128
|
||
public Vector3 AmbientColor; // 192
|
||
public float Emissive; // 204
|
||
public Vector3 SunColor; // 208
|
||
public float DiffuseFactor; // 220
|
||
public Vector3 SunDir; // 224
|
||
public float Transparency; // 236
|
||
public Vector2 UvScroll; // 240
|
||
public float ApplyFog; // 248
|
||
public float SurfOpacity; // 252
|
||
|
||
/// <summary>256 — the std140 size of the block, a whole number of vec4s.</summary>
|
||
public const int SizeInBytes = 256;
|
||
}
|
||
|
||
private sealed class SubMeshGpu
|
||
{
|
||
/// <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.
|
||
/// 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;
|
||
public int IndexCount;
|
||
public uint SurfaceId;
|
||
/// <summary>
|
||
/// True if the Surface's <c>SurfaceType.Additive</c> flag (0x10000)
|
||
/// is set. Drives the blend func switch (GL_ONE vs GL_ONE_MINUS_SRC_ALPHA).
|
||
/// Computed once at upload; avoids a per-frame dat lookup.
|
||
/// </summary>
|
||
public bool IsAdditive;
|
||
/// <summary>
|
||
/// <c>Surface.Luminosity</c> float (0..1 — NOT the SurfaceType.Luminous
|
||
/// flag bit). Passed to the sky fragment shader as <c>uEmissive</c>;
|
||
/// when 1.0 it saturates the lighting math so the mesh renders at
|
||
/// full texture brightness (dome, sun). When 0.0 the mesh picks up
|
||
/// the time-of-day ambient+diffuse tint (clouds). See
|
||
/// <c>docs/research/2026-04-23-sky-retail-verbatim.md</c> §6.
|
||
/// </summary>
|
||
public float SurfLuminosity;
|
||
public float SurfDiffuse;
|
||
/// <summary>
|
||
/// True when the source mesh's authored UVs exceed [0,1] (e.g.
|
||
/// the inner sky/star layer 0x010015EF and the cloud meshes —
|
||
/// they tile their texture across the geometry). The renderer
|
||
/// must use <c>GL_REPEAT</c> for these or only the small region
|
||
/// where UVs fall in [0,1] samples the actual texture; the rest
|
||
/// clamps to the edge texel ("square in one corner" symptom).
|
||
/// Computed once at mesh build from the actual UV range.
|
||
/// </summary>
|
||
public bool NeedsUvRepeat;
|
||
/// <summary>
|
||
/// Final surface opacity from <see cref="GfxObjSubMesh.SurfOpacity"/>.
|
||
/// Translucent surfaces use <c>1 - Surface.Translucency</c>; other
|
||
/// surfaces stay at 1.0.
|
||
/// </summary>
|
||
public float SurfOpacity;
|
||
public bool DisableFog;
|
||
}
|
||
}
|