feat(render): V6e — the sky's uniforms become a buffer and its texture a table slot

Campaign V slice V6e, last of three. Sky was the hardest of the four pairs
because it was the only one that still worked the way a 2004 shader works: a
dozen loose uniforms pushed one glUniform call at a time, and a texture bound to
unit 0 with a sampler object chosen per submesh. Vulkan GLSL has neither a
default uniform block nor a way to declare a bare sampler, so both had to move —
and the second one had a sting in it.

The uniforms go into a `SkyParams` std140 block at uniform binding 4, the new
pre-authorized constant in GpuBindingModel (1, 2 and 3 are SceneLighting, the
terrain clip block and terrain tiling; the contract test now proves the three
constants and that literal 2 do not collide). Three matrices are 192 bytes on
their own, so the 96-byte push-constant block was never in the running. The
block's member order IS its layout: std140 aligns a vec3 to 16 bytes while using
12, so each of the three lighting vectors is followed by the float that rides in
its pad word, which is why colours and per-surface scalars interleave rather
than grouping by meaning. SkyParamsLayoutTests asserts all twelve offsets and
the 256-byte size, because getting one member wrong would read the sun direction
as a colour with no compile error, no link error and no GL error to say so.

The texture is the interesting half. sky.frag now reads through the shared table
(ACDREAM_SAMPLE_2D), and a bindless handle BAKES its sampler — so the
per-submesh Repeat-versus-ClampToEdge choice, which used to be a glBindSampler
on unit 0, becomes which slot the submesh asks for. SkyRenderer interns one
handle per (texture, wrap) pair, exactly as ManagedGLTextureArray has done since
the world path went bindless, and exactly the shape Vulkan's table has, where an
entry is a combined image sampler. Same two SamplerCache objects, same wrap
behaviour, consulted once at interning instead of once per draw. A pleasant
consequence: the sky no longer touches texture unit 0, so the load-bearing
`BindSampler(0, 0)` restore at the end of the pass — there because the binding
was global state that would otherwise force ClampToEdge on the next renderer —
has nothing left to undo and is gone.

Gates. Release build clean; App tests 4,072 passed / 3 skipped (4,057 baseline,
plus the sentinel guard from the previous commit and fourteen sky-layout
assertions). Offline pixel gate against 95f8c25f: 18 px of 563,200 compared
(3.20e-05), inside the documented 15–23 px band.

That gate masks the sky for determinism, so it proves nothing about this commit
and the sky renderer has no automated pixel coverage at all. What was done
instead: a base-versus-head offline capture at ALL SEVEN day groups, built by
stashing the change and rebuilding so the two runs differ only in this commit.
Every pair matches in gradient, cloud sheet, horizon band and fog — including
day group 2's salmon cloud band and day group 6's green one, which between them
exercise texture sampling, per-vertex tint, blend mode and fog. Then 3/3
RENDERED on the desktop-witness repeat-connected gate.

That bounds the risk; it does not close it. The offline camera is fixed and
looks down, so a thin band of dome is all it ever sees: the sun and moon
(additive, high) and the rain cylinder (the one sky mesh that surrounds the
camera, and the one whose REPEAT wrap is most visible) remain unproven. Recorded
as user-gate debt in §5.1 alongside V2c's and V4e's particles — check it by
standing outside at dawn or dusk, and by standing in rain.

Manifest: 8/9 pairs compile. `terrain_modern` is the last production pair, and
it is blocked on V4d's content rather than on dialect — details in §5.5's slice
table. `mesh` has no consumer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-28 09:54:13 +02:00
parent 602bc9dddb
commit 7faaaa347b
11 changed files with 480 additions and 60 deletions

View file

@ -512,6 +512,7 @@ session, rather than one at a time:
| V4f | Sky — deliberately masked for determinism |
| V4g | Paperdoll and appraisal viewports, portal transit |
| V6d | The paperdoll/appraisal viewport sprite. It is the one retained-UI texture the gate's scene never draws, and V6d changed how every UI texture is sampled — from a bound texture unit to a table slot. The seam that registers it (`GlGpuDevice.RegisterExternalColorTexture`) is unchanged and its handle is now simply encoded rather than resolved back to a GL name, but that path is unproven by anything automated. Check it with the dungeon/portal pass above rather than on its own |
| V6e | **Particles, again — and now sky.** The particle half is V2c's and V4e's debt restated: the offline scene draws no particles, so nothing automated saw the varying retype or the ACDREAM_TEXTURE_NONE sentinel. The sky half is new and larger: V6e moved a dozen loose uniforms into a `SkyParams` uniform buffer and moved the sky's texture from a bound unit-0 texture-plus-sampler to a bindless (texture, wrap) table slot, and the gate masks the sky band for determinism. What WAS checked, and should be read as bounding the risk rather than closing it: a base-versus-head offline capture at **all seven day groups**, matching in gradient, cloud sheet, horizon band and fog on every one — including day group 2's salmon cloud band and day group 6's green band, which exercise texture sampling, tint, blend and fog together — plus 3/3 RENDERED on the desktop-witness repeat gate. **What remains unproven is pixel-exactness and the parts of the dome the fixed outdoor camera cannot see**: the sun and moon (additive surfaces high in the sky) and the rain cylinder, which is the one sky mesh that surrounds the camera and the one whose REPEAT wrap mode is most visible. Stand outside at dawn or dusk, and stand in rain |
**The user confirmed on 2026-07-27 that the local ACE server is always available
and they will verify visually on request.** That converts this table from deferred
@ -554,7 +555,18 @@ because sample positions are not specified across implementations.
| **V4g****re-sequenced — §5.5.5** | `PrivateEntityViewportRenderer``IGpuRenderTarget`; `PortalDepthMaskRenderer` + `PortalTunnelPresentation` → stencil/depth-mask pipelines. | pixel gate incl. paperdoll and portal transit |
| **V4h****re-sequenced — §5.5.5** | Frame-spine formalization: pass executors emit real declared `BeginPass`/`EndPass` (clears and framebuffer management move out of the spine and into pass load/store ops), flight/screenshot/resize/profiler move onto the RHI, `OpenGLGraphicsDevice`'s live role retires, Chorizite consumers are audited, and the architecture test lands. **Milestone: seam complete.** | pixel + connected lifecycle + R6 soak + complete Release suite + interim perf (RHI-on-GL CPU p50 ≤ 1.95 ms) |
| **V5** ✅ | Vulkan bring-up, dark: `ACDREAM_RENDER_BACKEND`, surface/instance/device/queues/swapchain, the capability record/probe/guard with the exit-4 contract, a clear-colour loop with screenshot and clean shutdown. | VK boots to clear on the RX 9070 XT; forced-unsupported knob → exit 4 |
| **V6** | Vulkan RHI backend, dark, four sequential commits: **a** ✅ allocator/buffers/staging/rings/timeline (`fb9c6693`); **b** ✅ textures/BC mips/samplers/descriptor table/render targets/MSAA resolve (`9eae4963`); **c**`.spv` toolchain, pipelines, pipeline cache, negative viewport, push constants, timestamps, readback, debug names (`234fe91d`); **d** ✅ first production renderers — `TextRenderer` and `DebugLineRenderer` on both backends, the colour-format contract amendment, and the retained UI drawn on Vulkan. **Milestone deferred:** "full game frame on Vulkan" is not reachable while V4c/V4d are parked and the world renderers plus `TextureCache` are still raw GL, so V6 delivers the backend and the two renderers that can use it today. | per-commit build + tests; V6d additionally pixel-gates GL and captures a Vulkan UI frame |
| **V6** | Vulkan RHI backend, dark, four sequential commits: **a** ✅ allocator/buffers/staging/rings/timeline (`fb9c6693`); **b** ✅ textures/BC mips/samplers/descriptor table/render targets/MSAA resolve (`9eae4963`); **c**`.spv` toolchain, pipelines, pipeline cache, negative viewport, push constants, timestamps, readback, debug names (`234fe91d`); **d** ✅ first production renderers — `TextRenderer` and `DebugLineRenderer` on both backends, the colour-format contract amendment, and the retained UI drawn on Vulkan; **e** ✅ every remaining production shader crosses the dialect — `mesh_modern` (`935f4dc3`), both particle pairs (`602bc9dd`) and `sky` — leaving 8/9 pairs compiling to SPIR-V. **Milestone deferred:** "full game frame on Vulkan" is not reachable while V4c/V4d are parked and the world renderers plus `TextureCache` are still raw GL, so V6 delivers the backend, the two renderers that can use it today, and the shaders the Vulkan world path will be built on. | per-commit build + tests; V6d additionally pixel-gates GL and captures a Vulkan UI frame; V6e pixel-gates GL per commit and adds a seven-day-group sky comparison plus a 3-run desktop-witness gate |
**The one production pair still not Vulkan-expressible after V6e is `terrain_modern`**,
blocked on exactly the two things V4d was going to do: `uView`/`uProjection` are
two loose `mat4` uniforms (128 bytes — they cannot both fit the 96-byte push
block, which is why V4d's first sub-commit converged them into one
`uViewProjection` on its own pixel gate), and `uTexTiling[36]` is the 144-byte
array `UniformTerrainTiling` was reserved for. V6e left it alone because
converging the matrices moves a multiply from per-vertex GPU to a CPU multiply —
a real numeric change that the plan requires be attributable on its own gate,
and one that belongs to whoever re-lands V4d's content rather than to a shader
dialect slice. `mesh` is a tenth pair with no consumer at all; see the V6e report.
| **V7** | GL-versus-Vulkan differential: `tools/run-backend-differential-gate.ps1`, strict paired-PNG compare, divergences fixed in the Vulkan backend only, then lifecycle + R6 soak natively on Vulkan, one validation-layer-clean run, one RenderDoc capture. **Milestone: parity.** | every differential checkpoint passes; both connected routes green on VK |
| **V8** | Perf gate on the RX 9070 XT, uncapped, both backends, same route. | §2 acceptance table; parity is the floor |
| **V9** | Linux + CI: X11/Wayland surfaces; a `linux-vulkan` job on lavapipe (probe accepts on a real 1.3 software device, a short real render under xvfb, forced-unsupported → exit 4, `.spv` freshness). Physical Linux GPU row deferred post-cutover, as for Slice L. | CI green including the new job |

View file

@ -968,7 +968,10 @@ internal sealed class LivePresentationCompositionPhase
() => new AcDream.App.Rendering.Shader(
d.Gl,
Path.Combine(foundation.ShadersDirectory, "sky.vert"),
Path.Combine(foundation.ShadersDirectory, "sky.frag")));
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 = scope.Own(
"sky shader lifetime",
skyShader,
@ -980,7 +983,10 @@ internal sealed class LivePresentationCompositionPhase
content.Dats,
skyShader,
foundation.TextureCache,
foundation.Samplers),
foundation.Samplers,
// Slice V6e: the sky samples through the binding=9 handle table,
// so it needs the same bindless entry point the world path uses.
foundation.Bindless),
static value => value.Dispose());
var particleLease = scope.Acquire(
"particle renderer",

View file

@ -89,6 +89,21 @@ internal static class GpuBindingModel
/// </summary>
public const uint UniformTerrainTiling = 3;
/// <summary>
/// Sky per-draw parameters — the sky/model transforms, the keyframe's
/// ambient/sun colours and sun direction, the UV scroll, and the
/// per-surface emissive/diffuse/opacity/fog scalars.
///
/// Added at slice V6e for the same reason as
/// <see cref="UniformTerrainTiling"/>: Vulkan GLSL has no default uniform
/// block, so a loose <c>uniform mat4 uSkyView;</c> is unspellable, and this
/// set is 256 bytes in std140 — three matrices alone are twice the entire
/// 96-byte push-constant block. A uniform buffer is the only legal home.
/// Bindings 1, 2 and 3 are taken by SceneLighting, the terrain clip block
/// and terrain tiling, so this is 4.
/// </summary>
public const uint UniformSkyParams = 4;
/// <summary>Set index carrying every uniform buffer.</summary>
public const uint UniformSet = 1;

View file

@ -1,14 +1,41 @@
#version 430 core
#extension GL_ARB_bindless_texture : require
in vec2 vTex;
in vec3 vTint;
in float vFogFactor; // 1 = no fog, 0 = full fog color
out vec4 fragColor;
uniform sampler2D uDiffuse;
uniform float uTransparency; // keyframe transparency: 0 visible, 1 transparent
uniform float uApplyFog; // 1 for foggable sky layers; raw-additive surfaces keep retail fog disabled
uniform float uSurfOpacity; // final surface opacity multiplier from the CPU
// Campaign V slice V6e: the sky's texture is now read through the shared table
// (ACDREAM_SAMPLE_2D, common.glsl) rather than a `uniform sampler2D` bound to
// texture unit 0. Vulkan has no default uniform block to declare a loose
// sampler in, and set 2 is where every sampled texture lives.
//
// The wrap mode travels WITH the slot: SkyRenderer registers a distinct table
// entry per (texture, sampler) pair, so the per-submesh Repeat-vs-ClampToEdge
// choice that used to be a glBindSampler call is now which slot it asks for.
// That is the same shape the Vulkan table has, where a table entry is a
// combined image sampler.
uniform uint uTextureIndexA;
// The per-draw block sky.vert declares. A uniform block must be declared
// identically in every stage of a program that names it — the same rule the
// SceneLighting block below has always followed — so the whole thing appears
// here even though the fragment stage reads only the last three scalars.
layout(std140, ACDREAM_UBO_SET binding = 4) uniform SkyParams {
mat4 uModel;
mat4 uSkyView;
mat4 uSkyProjection;
vec3 uAmbientColor;
float uEmissive;
vec3 uSunColor;
float uDiffuseFactor;
vec3 uSunDir;
float uTransparency;
vec2 uUvScroll;
float uApplyFog;
float uSurfOpacity;
};
struct Light {
vec4 posAndKind;
@ -16,7 +43,7 @@ struct Light {
vec4 colorAndIntensity;
vec4 coneAngleEtc;
};
layout(std140, binding = 1) uniform SceneLighting {
layout(std140, ACDREAM_UBO_SET binding = 1) uniform SceneLighting {
Light uLights[8];
vec4 uCellAmbient;
vec4 uFogParams;
@ -25,7 +52,7 @@ layout(std140, binding = 1) uniform SceneLighting {
};
void main() {
vec4 sampled = texture(uDiffuse, vTex);
vec4 sampled = ACDREAM_SAMPLE_2D(uTextureIndexA, vTex);
vec3 rgb = sampled.rgb * vTint;

View file

@ -35,19 +35,39 @@ layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec2 aTex;
uniform mat4 uModel;
uniform mat4 uSkyView;
uniform mat4 uSkyProjection;
uniform vec2 uUvScroll;
// Campaign V slice V6e: everything below used to be a dozen loose uniforms set
// with glUniform* between draws. Vulkan GLSL has no default uniform block, so
// those declarations are not merely unsupported — they are unspellable — and
// three matrices are already twice the whole 96-byte push-constant block. A
// uniform buffer is the only legal home, and the same declaration is legal in
// both dialects (GpuBindingModel.UniformSkyParams).
//
// The member ORDER is the std140 layout and must stay in step with
// SkyRenderer's SkyParams struct: each vec3 is followed by the float that rides
// in its 4-byte pad word, which is why the lighting colours and the per-surface
// scalars interleave. Offsets are asserted by SkyParamsLayoutTests.
//
// uModel 0 uSkyView 64 uSkyProjection 128
// uAmbientColor 192 uEmissive 204
// uSunColor 208 uDiffuseFactor 220
// uSunDir 224 uTransparency 236
// uUvScroll 240 uApplyFog 248 uSurfOpacity 252 (size 256)
layout(std140, ACDREAM_UBO_SET binding = 4) uniform SkyParams {
mat4 uModel;
mat4 uSkyView;
mat4 uSkyProjection;
// Per-frame lighting (from SkyKeyframe):
uniform vec3 uAmbientColor; // AmbColor × AmbBright (retail light.Ambient)
uniform vec3 uSunColor; // DirColor × DirBright (retail light.Diffuse)
uniform vec3 uSunDir; // unit vector FROM surface TO sun
// Per-submesh (from Surface.Luminosity float):
uniform float uEmissive;
uniform float uDiffuseFactor;
// Per-frame lighting (from SkyKeyframe):
vec3 uAmbientColor; // AmbColor × AmbBright (retail light.Ambient)
float uEmissive; // per-submesh Surface.Luminosity
vec3 uSunColor; // DirColor × DirBright (retail light.Diffuse)
float uDiffuseFactor;
vec3 uSunDir; // unit vector FROM surface TO sun
float uTransparency; // keyframe transparency: 0 visible, 1 transparent
vec2 uUvScroll;
float uApplyFog; // 1 for foggable layers; raw-additive keeps fog off
float uSurfOpacity; // final surface opacity multiplier from the CPU
};
// Shared SceneLighting UBO — we need uFogParams.xy (fog start/end) to
// compute the vertex fog factor. Must match sky.frag's declaration.
@ -57,7 +77,7 @@ struct Light {
vec4 colorAndIntensity;
vec4 coneAngleEtc;
};
layout(std140, binding = 1) uniform SceneLighting {
layout(std140, ACDREAM_UBO_SET binding = 1) uniform SceneLighting {
Light uLights[8];
vec4 uCellAmbient;
vec4 uFogParams; // x=fogStart, y=fogEnd, z=flash, w=fogMode
@ -80,7 +100,7 @@ layout(std140, binding = 1) uniform SceneLighting {
// ungates the sky entirely (the second loop sets all 8 distances to +1.0 ⇒
// full-screen sky, bit-identical to pre-Stage-4). Host enables GL_CLIP_DISTANCE0..7
// only around the sky/weather draws.
layout(std140, binding = 2) uniform TerrainClip {
layout(std140, ACDREAM_UBO_SET binding = 2) uniform TerrainClip {
int uTerrainClipCount;
vec4 uTerrainClipPlanes[8];
};

View file

@ -85,19 +85,17 @@
},
{
"name": "sky",
"vulkanReady": false,
"vulkanReady": true,
"stages": [
{
"stage": "vert",
"sourceSha256": "d338e9b03686b7baf79d5121c5c8d0f24037979cc58f203957d7bd97b02b1cc2",
"compiled": false,
"message": "sky.vert:153: error: \u0027uUvScroll\u0027 : undeclared identifier"
"sourceSha256": "a89580f54c8d36b33d50b84b9fb61024861c737bb0b5bd7a4df0704bf010c3c3",
"compiled": true
},
{
"stage": "frag",
"sourceSha256": "8084af39f65ae399c73e3ca864376ef20ba8a1c495ee4774be6a82af3872c51c",
"compiled": false,
"message": "sky.frag:78: error: \u0027uDiffuse\u0027 : undeclared identifier"
"sourceSha256": "bcb21fd47fc6f74a75edd349bc3dff6120b8995115b2a08269a22f98c0bedd6c",
"compiled": true
}
]
},

Binary file not shown.

Binary file not shown.

View file

@ -51,6 +51,30 @@ public sealed unsafe class SkyRenderer : IDisposable
private readonly TextureCache _textures;
private readonly SamplerCache _samplers;
// Campaign V slice V6e. Three 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.
// _textureTableSsbo — the binding=9 handle table (the GL-only emulation
// of Vulkan's set 2), holding one entry per
// (texture, wrap-mode) pair the sky samples.
// _uTextureIndexALoc — the push-constant-named uniform carrying the slot.
//
// The wrap mode is what makes the 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.
private readonly Wb.BindlessSupport _bindless;
private readonly GlBindlessHandleTable _textureTable = new();
private readonly Dictionary<(uint Texture, bool Repeat), ulong> _handleByTextureAndWrap = new();
private uint _paramsUbo;
private uint _textureTableSsbo;
private int _textureTableSsboCapacityBytes;
private int _uTextureIndexALoc = -1;
private SkyParams _params;
// Lazily-built GPU resources per sky-GfxObj.
private readonly Dictionary<uint, List<SubMeshGpu>> _gpuByGfxObj = new();
@ -63,13 +87,39 @@ public sealed unsafe class SkyRenderer : IDisposable
public float Near { get; set; } = 0.1f;
public float Far { get; set; } = 1_000_000f;
public SkyRenderer(GL gl, IDatReaderWriter dats, Shader shader, TextureCache textures, SamplerCache samplers)
public SkyRenderer(
GL gl,
IDatReaderWriter dats,
Shader shader,
TextureCache textures,
SamplerCache samplers,
Wb.BindlessSupport bindless)
{
_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));
_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);
_textureTableSsbo = Wb.TrackedGlResource.CreateBuffer(
_gl, "sky texture-table SSBO creation");
}
/// <summary>
@ -179,17 +229,27 @@ public sealed unsafe class SkyRenderer : IDisposable
skyView.M43 = 0f;
_shader.Use();
_shader.SetMatrix4("uSkyView", skyView);
_shader.SetMatrix4("uSkyProjection", skyProj);
// 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.
_shader.SetVec3("uAmbientColor", keyframe.AmbientColor);
_shader.SetVec3("uSunColor", keyframe.SunColor);
_shader.SetVec3("uSunDir",
AcDream.Core.World.SkyStateProvider.SunDirectionFromKeyframe(keyframe));
_params.AmbientColor = keyframe.AmbientColor;
_params.SunColor = keyframe.SunColor;
_params.SunDir =
AcDream.Core.World.SkyStateProvider.SunDirectionFromKeyframe(keyframe);
_gl.BindBufferBase(
BufferTargetARB.UniformBuffer,
AcDream.App.Rendering.Gpu.GpuBindingModel.UniformSkyParams,
_paramsUbo);
// Save + override GL state.
_gl.DepthMask(false);
@ -308,15 +368,15 @@ public sealed unsafe class SkyRenderer : IDisposable
if (postScenePass && obj.IsWeather && (obj.Properties & 0x08u) == 0u)
model = model * Matrix4x4.CreateTranslation(0f, 0f, -120f);
_shader.SetMatrix4("uModel", model);
_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;
_shader.SetVec2("uUvScroll", new Vector2(uOffset, vOffset));
_shader.SetFloat("uTransparency", transparent);
_params.UvScroll = new Vector2(uOffset, vOffset);
_params.Transparency = transparent;
EnsureMeshUploaded(gfxObjId);
if (!_gpuByGfxObj.TryGetValue(gfxObjId, out var subMeshes)) continue;
@ -363,14 +423,14 @@ public sealed unsafe class SkyRenderer : IDisposable
float effDiffuse = float.IsNaN(replaceDiffuse)
? sub.SurfDiffuse
: replaceDiffuse;
_shader.SetFloat("uEmissive", effEmissive);
_shader.SetFloat("uDiffuseFactor", effDiffuse);
_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.
_shader.SetFloat("uSurfOpacity", sub.SurfOpacity);
_params.SurfOpacity = sub.SurfOpacity;
// Retail D3DPolyRender::SetSurface at 0x59c882 calls
// SetFFFogAlphaDisabled(1) when the Additive flag (0x10000)
@ -383,11 +443,9 @@ public sealed unsafe class SkyRenderer : IDisposable
// 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.
_shader.SetFloat("uApplyFog", sub.DisableFog ? 0f : 1f);
_params.ApplyFog = sub.DisableFog ? 0f : 1f;
uint tex = _textures.GetOrUpload(sub.SurfaceId);
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, tex);
// Sky meshes need per-object wrap mode driven by the
// mesh's authored UV range, not by TexVelocity:
@ -411,16 +469,30 @@ public sealed unsafe class SkyRenderer : IDisposable
// UV offset can drift outside [0,1] regardless of authored
// range, and they'd show their own seam bleed otherwise).
//
// Implementation: bind a persistent sampler object to
// texture unit 0. Sampler state overrides the texture's
// own wrap state, so two renderers can share the same
// texture handle but sample it with different wrap modes
// safely. Ported from WorldBuilder
// 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;
_gl.BindSampler(0, needsRepeat ? _samplers.Wrap : _samplers.Clamp);
_gl.ProgramUniform1(
_shader.Program,
_uTextureIndexALoc,
TextureTableSlot(tex, needsRepeat));
UploadParams();
FlushAndBindTextureTable();
_gl.BindVertexArray(sub.Vao);
_gl.DrawElements(PrimitiveType.Triangles,
@ -431,12 +503,13 @@ public sealed unsafe class SkyRenderer : IDisposable
}
// Restore GL state expected by the rest of the pipeline.
// Critical: unbind the sampler from unit 0. While bound, sampler
// state overrides the texture's own wrap parameters, so leaving
// (e.g.) Clamp bound would silently force ClampToEdge on every
// subsequent draw on unit 0 regardless of how that texture was
// configured at upload time.
_gl.BindSampler(0, 0);
//
// 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.
_gl.Disable(EnableCap.Blend);
_gl.DepthMask(true);
_gl.Enable(EnableCap.DepthTest);
@ -444,6 +517,88 @@ public sealed unsafe class SkyRenderer : IDisposable
_gl.BindVertexArray(0);
}
/// <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 textureName, bool repeat)
{
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 _textureTable.GetOrAdd(handle);
}
/// <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()
{
fixed (void* p = &_params)
{
_gl.BindBuffer(BufferTargetARB.UniformBuffer, _paramsUbo);
_gl.BufferSubData(
BufferTargetARB.UniformBuffer, 0, (nuint)SkyParams.SizeInBytes, p);
}
}
/// <summary>
/// Uploads newly interned handles to the binding=9 table and (re)binds it.
/// 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()
{
if (_textureTable.Dirty)
{
ReadOnlySpan<ulong> handles = _textureTable.Handles;
int byteCount = handles.Length * sizeof(ulong);
fixed (ulong* p = handles)
{
_gl.BindBuffer(BufferTargetARB.ShaderStorageBuffer, _textureTableSsbo);
if (_textureTableSsboCapacityBytes < byteCount)
{
int grown = DynamicBufferCapacity.Grow(_textureTableSsboCapacityBytes, byteCount);
Wb.TrackedGlResource.AllocateBufferStorage(
_gl,
BufferTargetARB.ShaderStorageBuffer,
_textureTableSsbo,
_textureTableSsboCapacityBytes,
grown,
BufferUsageARB.DynamicDraw,
"growing sky texture-table SSBO");
_textureTableSsboCapacityBytes = grown;
}
_gl.BufferSubData(BufferTargetARB.ShaderStorageBuffer, 0, (nuint)byteCount, p);
}
_textureTable.MarkFlushed();
}
_gl.BindBufferBase(
BufferTargetARB.ShaderStorageBuffer,
AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable,
_textureTableSsbo);
}
/// <summary>
/// Find the <see cref="SkyObjectReplaceData"/> entries for the
/// keyframe currently "active" at <paramref name="dayFraction"/>.
@ -737,6 +892,62 @@ public sealed unsafe class SkyRenderer : IDisposable
}
}
_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.
foreach (ulong handle in _handleByTextureAndWrap.Values)
_bindless.MakeNonResident(handle);
_handleByTextureAndWrap.Clear();
if (_paramsUbo != 0)
{
Wb.TrackedGlResource.DeleteBuffer(
_gl, _paramsUbo, SkyParams.SizeInBytes, "sky params UBO disposal");
_paramsUbo = 0;
}
if (_textureTableSsbo != 0)
{
Wb.TrackedGlResource.DeleteBuffer(
_gl,
_textureTableSsbo,
_textureTableSsboCapacityBytes,
"sky texture-table SSBO disposal");
_textureTableSsbo = 0;
_textureTableSsboCapacityBytes = 0;
}
}
/// <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

View file

@ -129,15 +129,23 @@ public sealed class GpuContractTests
[Fact]
public void UniformBindingsDoNotCollide()
{
// Campaign V slice V6e added the sky block. Vulkan has ONE binding
// namespace per set, so two uniform buffers sharing a number is not a
// style problem — it is one of them silently reading the other's bytes.
uint[] uniformBindings =
[
GpuBindingModel.UniformSceneLighting,
GpuBindingModel.UniformTerrainTiling,
GpuBindingModel.UniformSkyParams,
];
Assert.Equal(uniformBindings.Length, uniformBindings.Distinct().Count());
// Binding 2 is the terrain clip block; the tiling array must not take it.
Assert.NotEqual(2u, GpuBindingModel.UniformTerrainTiling);
// Binding 2 is the terrain clip block, which sky.vert also reads and
// which has no constant here because no CPU writer names it through the
// binding model. It is spelled as a literal on purpose: a new uniform
// buffer that took 2 would compile, link, and render the wrong thing.
Assert.DoesNotContain(2u, uniformBindings);
}
[Fact]

View file

@ -0,0 +1,123 @@
using System;
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// Campaign V slice V6e: the sky's dozen loose uniforms became one std140
/// uniform block, and this is the test that keeps the CPU struct and the GLSL
/// block describing the same 256 bytes.
///
/// <para>It exists because the failure mode is silent. std140 gives a
/// <c>vec3</c> 16-byte alignment while only using 12, so the block deliberately
/// parks a <c>float</c> in each of those pad words — which is why the lighting
/// colours and the per-surface scalars interleave rather than being grouped by
/// meaning. Get one member out of order and the shader reads the sun direction
/// where a colour should be: no compile error, no link error, no GL error, just
/// a wrong sky that only a human looking at the screen would catch. The offline
/// pixel gate masks the sky band for determinism, so nothing automated is
/// watching. This is the substitute.</para>
/// </summary>
public sealed class SkyParamsLayoutTests
{
private static Type SkyParamsType =>
typeof(AcDream.App.Rendering.Sky.SkyRenderer)
.GetNestedType("SkyParams", BindingFlags.NonPublic)
?? throw new InvalidOperationException("SkyRenderer.SkyParams is missing.");
[Theory]
// Three transforms first: mat4 is 4 vec4s in std140, so these need no thought.
[InlineData("Model", 0)]
[InlineData("SkyView", 64)]
[InlineData("SkyProjection", 128)]
// Then three (vec3, float) couples. Each float rides in the pad word the
// vec3's 16-byte alignment would otherwise waste.
[InlineData("AmbientColor", 192)]
[InlineData("Emissive", 204)]
[InlineData("SunColor", 208)]
[InlineData("DiffuseFactor", 220)]
[InlineData("SunDir", 224)]
[InlineData("Transparency", 236)]
// Finally a vec2 (8-byte aligned) and the last two scalars, filling the
// sixteenth vec4 exactly.
[InlineData("UvScroll", 240)]
[InlineData("ApplyFog", 248)]
[InlineData("SurfOpacity", 252)]
public void EveryMemberSitsWhereStd140PutsIt(string member, int expectedOffset)
{
Assert.Equal(
new IntPtr(expectedOffset),
Marshal.OffsetOf(SkyParamsType, member));
}
[Fact]
public void TheBlockIsAWholeNumberOfVec4s()
{
int declared = (int)(SkyParamsType
.GetField("SizeInBytes", BindingFlags.Public | BindingFlags.Static)
?.GetRawConstantValue()
?? throw new InvalidOperationException("SkyParams.SizeInBytes is missing."));
Assert.Equal(256, declared);
Assert.Equal(declared, Marshal.SizeOf(SkyParamsType));
// std140 rounds a block up to its largest member's alignment (16).
Assert.Equal(0, declared % 16);
}
[Fact]
public void BothStagesDeclareTheBlockIdentically()
{
// A uniform block named in two stages of one program must be declared
// the same way in both, or the link fails — but only if someone reads
// it, and sky.frag reads three of the twelve members. Comparing the
// declarations directly means a divergence is caught by a test rather
// than by a driver's link log at startup.
string shaders = Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders");
string vertexBlock = ExtractSkyParamsBlock(File.ReadAllText(Path.Combine(shaders, "sky.vert")));
string fragmentBlock = ExtractSkyParamsBlock(File.ReadAllText(Path.Combine(shaders, "sky.frag")));
Assert.Equal(vertexBlock, fragmentBlock);
// And the binding must be the one the CPU binds the buffer to.
Assert.Equal(4u, AcDream.App.Rendering.Gpu.GpuBindingModel.UniformSkyParams);
Assert.Contains("binding = 4) uniform SkyParams {", vertexBlock);
}
/// <summary>
/// The block's declaration with comments and whitespace runs collapsed, so
/// the comparison is about members rather than formatting.
/// </summary>
private static string ExtractSkyParamsBlock(string source)
{
int start = source.IndexOf("layout(std140", StringComparison.Ordinal);
while (start >= 0)
{
int end = source.IndexOf("};", start, StringComparison.Ordinal);
Assert.True(end > start, "A layout(std140 …) block was never closed.");
string block = source[start..(end + 2)];
if (block.Contains("uniform SkyParams", StringComparison.Ordinal))
return Normalise(block);
start = source.IndexOf("layout(std140", end, StringComparison.Ordinal);
}
throw new InvalidOperationException("No SkyParams block found in the shader source.");
}
private static string Normalise(string block)
{
var text = new System.Text.StringBuilder();
foreach (string rawLine in block.Replace("\r\n", "\n").Split('\n'))
{
int comment = rawLine.IndexOf("//", StringComparison.Ordinal);
string line = comment >= 0 ? rawLine[..comment] : rawLine;
string collapsed = string.Join(' ', line.Split(
(char[]?)null, StringSplitOptions.RemoveEmptyEntries));
if (collapsed.Length > 0)
text.Append(collapsed).Append('\n');
}
return text.ToString();
}
}