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:
parent
0df1c5b4a6
commit
9957070cab
15 changed files with 1255 additions and 91 deletions
292
src/AcDream.App/Rendering/Sky/SkyRenderer.cs
Normal file
292
src/AcDream.App/Rendering/Sky/SkyRenderer.cs
Normal 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.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 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue