feat(render): Phase G.1/G.2 — SceneLighting UBO + sky renderer + shader integration

Wire the existing LightManager + WorldTimeService state into visible
rendering. Every draw call (terrain, static mesh, instanced mesh, sky)
now shares one SceneLighting UBO at binding=1 carrying:
  - 8 Light slots (Directional / Point / Spot, retail hard-cutoff)
  - Ambient RGB + active light count
  - Fog start/end/mode + color + lightning flash scalar
  - Camera world position + day fraction

The CPU side (SceneLightingUbo in Core.Lighting) is a POD struct that
gets BufferSubData'd once per frame from GameWindow.OnRender. Shaders
read the block via `layout(std140, binding = 1) uniform SceneLighting`
— no per-program uniform uploads.

Shader changes:
  - mesh.frag + mesh_instanced.frag accumulate 8 dynamic lights per
    fragment using the retail no-attenuation hard-cutoff model
    (r13 §10.2 / §13.1). Sun reads slot 0; spots use hard cos-cone test.
    Additive lightning flash + linear fog layered on top. Saturate
    clamps per-channel to 1.0.
  - terrain.vert bakes AdjustPlanes sun+ambient per vertex using the
    retail MIN_FACTOR = 0.08 ambient floor (r13 §7). terrain.frag adds
    fog + flash on top of the baked vertex color.
  - mesh.vert + mesh_instanced.vert emit vWorldPos so the fragment
    stage can do per-pixel lighting against world-space positions.
  - New sky.vert / sky.frag pair — unlit, scroll-UV, camera-centered,
    with its own 0.1..1e6 far plane. Ports WorldBuilder's skybox.

SkyRenderer (new file in App/Rendering/Sky/) ports WorldBuilder's
SkyboxRenderManager verbatim for the C# idiom: zeroed view translation,
dedicated projection, depth mask off, iterate each visible SkyObject
in the day group, apply arc transform (Z rot for heading + Y rot for
arc sweep), feed TexVelocityX/Y as a scrolling UV offset, apply
per-keyframe SkyObjectReplace overrides (mesh swap + transparency +
luminosity) for overcast / dusk cloud variants.

GameWindow integration:
  - OnLoad parses Region (0x13000000) into LoadedSkyDesc and hot-swaps
    WorldTime's provider to the dat-accurate keyframes. Seeds to noon
    for offline rendering. Creates the SceneLightingUboBinding and the
    SkyRenderer.
  - OnRender: set clear color from atmosphere fog, tick WeatherSystem,
    spawn/stop rain/snow camera-local emitters on kind change, feed
    sun to LightManager (zero intensity indoors — r13 §13.7), tick
    LightManager against viewer pos, build + upload the UBO, draw
    sky before terrain, draw terrain + static + instanced using the
    shared UBO.

5 new UBO packing tests (struct sizes, slot population, 8-light cap,
directional slot 0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-19 10:39:48 +02:00
parent 0df1c5b4a6
commit 9957070cab
15 changed files with 1255 additions and 91 deletions

View file

@ -0,0 +1,292 @@
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 DatReaderWriter.DBObjs;
using Silk.NET.OpenGL;
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.11e6 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 unsafe class SkyRenderer : IDisposable
{
private readonly GL _gl;
private readonly DatCollection _dats;
private readonly Shader _shader;
private readonly TextureCache _textures;
// 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;
// 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;
public SkyRenderer(GL gl, DatCollection dats, Shader shader, TextureCache textures)
{
_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));
}
/// <summary>
/// Draw the sky for this frame. Called FIRST in the render loop —
/// terrain / meshes / debug lines / overlay land on top.
/// </summary>
public void Render(
ICamera camera,
Vector3 cameraWorldPos,
float dayFraction,
DayGroupData? group)
{
if (group is null || group.SkyObjects.Count == 0) return;
// Build a sky projection with a huge far plane so 1e6m-distant
// celestial meshes don't clip. The FOV is cargo-culted from the
// camera's projection — see WorldBuilder's implementation.
float fovY = MathF.PI / 3f; // 60° — matches FlyCamera/ChaseCamera
float aspect = camera.Aspect;
if (aspect <= 0f) aspect = 16f / 9f;
var skyProj = Matrix4x4.CreatePerspectiveFieldOfView(fovY, aspect, 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;
_shader.Use();
_shader.SetMatrix4("uSkyView", skyView);
_shader.SetMatrix4("uSkyProjection", skyProj);
// Save + override GL state.
_gl.DepthMask(false);
_gl.Disable(EnableCap.DepthTest);
_gl.Disable(EnableCap.CullFace);
_gl.Enable(EnableCap.Blend);
_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.
var replaces = PickReplaces(group, dayFraction);
float secondsSinceStart = (float)(DateTime.UtcNow - _startedAt).TotalSeconds;
for (int i = 0; i < group.SkyObjects.Count; i++)
{
var obj = group.SkyObjects[i];
if (!obj.IsVisible(dayFraction)) continue;
// Apply per-keyframe replace overrides.
uint gfxObjId = obj.GfxObjId;
float headingDeg = 0f;
float transparent = 0f;
float luminosity = 1f;
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) luminosity = rep.Luminosity;
if (rep.MaxBright > 0f) luminosity = MathF.Min(luminosity, 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);
_shader.SetMatrix4("uModel", 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);
_shader.SetFloat("uLuminosity", luminosity);
_shader.SetVec4("uTint", Vector4.One);
EnsureMeshUploaded(gfxObjId);
if (!_gpuByGfxObj.TryGetValue(gfxObjId, out var subMeshes)) continue;
foreach (var sub in subMeshes)
{
uint tex = _textures.GetOrUpload(sub.SurfaceId);
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, tex);
_gl.BindVertexArray(sub.Vao);
_gl.DrawElements(PrimitiveType.Triangles,
(uint)sub.IndexCount,
DrawElementsType.UnsignedInt,
(void*)0);
}
}
// Restore GL state expected by the rest of the pipeline.
_gl.Disable(EnableCap.Blend);
_gl.DepthMask(true);
_gl.Enable(EnableCap.DepthTest);
_gl.BindVertexArray(0);
}
/// <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 GfxObj build — reuses <see cref="GfxObjMesh"/> so the
/// pos/neg polygon splitting logic stays consistent with the main
/// static-mesh pipeline. Most sky meshes are single-surface.
/// </summary>
private void EnsureMeshUploaded(uint gfxObjId)
{
if (_gpuByGfxObj.ContainsKey(gfxObjId)) return;
var gfx = _dats.Get<GfxObj>(gfxObjId);
if (gfx is null)
{
_gpuByGfxObj[gfxObjId] = new List<SubMeshGpu>();
return;
}
var subMeshes = GfxObjMesh.Build(gfx, _dats);
var gpuList = new List<SubMeshGpu>(subMeshes.Count);
foreach (var sm in subMeshes)
gpuList.Add(UploadSubMesh(sm));
_gpuByGfxObj[gfxObjId] = gpuList;
}
private SubMeshGpu UploadSubMesh(GfxObjSubMesh 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);
return new SubMeshGpu
{
Vao = vao,
Vbo = vbo,
Ebo = ebo,
IndexCount = sm.Indices.Length,
SurfaceId = sm.SurfaceId,
};
}
public void Dispose()
{
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();
}
private sealed class SubMeshGpu
{
public uint Vao;
public uint Vbo;
public uint Ebo;
public int IndexCount;
public uint SurfaceId;
}
}