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
|
|
@ -162,6 +162,23 @@ public sealed class GameWindow : IDisposable
|
|||
new AcDream.Core.World.WorldTimeService(
|
||||
AcDream.Core.World.SkyStateProvider.Default());
|
||||
public readonly AcDream.Core.Lighting.LightManager Lighting = new();
|
||||
public readonly AcDream.Core.World.WeatherSystem Weather = new();
|
||||
|
||||
// Phase G.1 sky renderer + shared UBO. Created once the GL context
|
||||
// exists in OnLoad; shared across every other renderer via
|
||||
// binding = 1 so terrain/mesh/instanced/sky all read the same
|
||||
// sun / ambient / fog / flash data per frame.
|
||||
private AcDream.App.Rendering.SceneLightingUboBinding? _sceneLightingUbo;
|
||||
private AcDream.App.Rendering.Sky.SkyRenderer? _skyRenderer;
|
||||
private AcDream.Core.World.LoadedSkyDesc? _loadedSkyDesc;
|
||||
|
||||
// Current rain/snow emitter handles — spawned on weather-kind change
|
||||
// and stopped when the kind leaves Rain/Snow. Non-zero == active.
|
||||
private int _rainEmitterHandle;
|
||||
private int _snowEmitterHandle;
|
||||
private AcDream.Core.World.WeatherKind _lastWeatherKind =
|
||||
AcDream.Core.World.WeatherKind.Clear;
|
||||
private double _weatherAccum;
|
||||
|
||||
// Phase B.2: player movement mode.
|
||||
private AcDream.App.Input.PlayerMovementController? _playerController;
|
||||
|
|
@ -566,6 +583,12 @@ public sealed class GameWindow : IDisposable
|
|||
Path.Combine(shadersDir, "mesh_instanced.vert"),
|
||||
Path.Combine(shadersDir, "mesh_instanced.frag"));
|
||||
|
||||
// Phase G.1/G.2: shared scene-lighting UBO. Stays bound at
|
||||
// binding=1 for the lifetime of the process — every shader that
|
||||
// declares `layout(std140, binding = 1) uniform SceneLighting`
|
||||
// reads from this without further intervention.
|
||||
_sceneLightingUbo = new SceneLightingUboBinding(_gl);
|
||||
|
||||
_debugLines = new DebugLineRenderer(_gl, shadersDir);
|
||||
|
||||
// Debug HUD: load a system monospace font and set up the text overlay.
|
||||
|
|
@ -640,6 +663,35 @@ public sealed class GameWindow : IDisposable
|
|||
if (heightTable is null || heightTable.Length < 256)
|
||||
throw new InvalidOperationException("Region.LandDefs.LandHeightTable missing or truncated");
|
||||
|
||||
// Phase G.1: parse the full sky descriptor (day groups, keyframes,
|
||||
// celestial mesh layers) and swap WorldTime's provider over to the
|
||||
// dat-backed keyframes. The stub default provider is only used if
|
||||
// the Region lacks HasSkyInfo.
|
||||
if (region is not null)
|
||||
{
|
||||
_loadedSkyDesc = AcDream.Core.World.SkyDescLoader.LoadFromRegion(region);
|
||||
if (_loadedSkyDesc is not null)
|
||||
{
|
||||
WorldTime.SetProvider(_loadedSkyDesc.BuildDefaultProvider());
|
||||
WorldTime.TickSize = _loadedSkyDesc.TickSize > 0 ? _loadedSkyDesc.TickSize : 1.0;
|
||||
Console.WriteLine(
|
||||
$"sky: loaded Region 0x13000000 — {_loadedSkyDesc.DayGroups.Count} day groups, " +
|
||||
$"TickSize={_loadedSkyDesc.TickSize}, LightTickSize={_loadedSkyDesc.LightTickSize}");
|
||||
if (_loadedSkyDesc.DefaultDayGroup is not null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"sky: default group '{_loadedSkyDesc.DefaultDayGroup.Name}' has " +
|
||||
$"{_loadedSkyDesc.DefaultDayGroup.SkyObjects.Count} sky objects, " +
|
||||
$"{_loadedSkyDesc.DefaultDayGroup.SkyTimes.Count} keyframes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Seed WorldTime to noon so outdoor scenes aren't pitch-black before
|
||||
// the server sends its first TimeSync packet (offline rendering in
|
||||
// particular never receives one).
|
||||
WorldTime.SyncFromServer(AcDream.Core.World.DerethDateTime.DayTicks * 0.5);
|
||||
|
||||
// Build the terrain atlas once from the Region dat.
|
||||
var terrainAtlas = AcDream.App.Rendering.TerrainAtlas.Build(_gl, _dats);
|
||||
|
||||
|
|
@ -676,6 +728,15 @@ public sealed class GameWindow : IDisposable
|
|||
_textureCache = new TextureCache(_gl, _dats);
|
||||
_staticMesh = new InstancedMeshRenderer(_gl, _meshShader, _textureCache);
|
||||
|
||||
// Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
|
||||
// with depth writes off + far plane 1e6 so celestial meshes
|
||||
// never clip. Shares the TextureCache with the static pipeline.
|
||||
var skyShader = new Shader(_gl,
|
||||
Path.Combine(shadersDir, "sky.vert"),
|
||||
Path.Combine(shadersDir, "sky.frag"));
|
||||
_skyRenderer = new AcDream.App.Rendering.Sky.SkyRenderer(
|
||||
_gl, _dats, skyShader, _textureCache);
|
||||
|
||||
// Phase A.1: replace the one-shot 3×3 preload with a streaming controller.
|
||||
// Parse runtime radius from environment (default 2 → 5×5 window).
|
||||
// Values outside [0, 8] fall back to the field default of 2.
|
||||
|
|
@ -2863,6 +2924,16 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
private void OnRender(double deltaSeconds)
|
||||
{
|
||||
// Phase G.1: set the clear color from the current sky's fog
|
||||
// tint so the horizon band continues naturally past the
|
||||
// rendered geometry. Fog blends to this color at max distance
|
||||
// so there's no visible seam. Updated each frame from the
|
||||
// interpolated keyframe.
|
||||
var kf = WorldTime.CurrentSky;
|
||||
var atmo = Weather.Snapshot(in kf);
|
||||
var fogColor = atmo.FogColor;
|
||||
_gl!.ClearColor(fogColor.X, fogColor.Y, fogColor.Z, 1f);
|
||||
|
||||
_gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
|
||||
|
||||
// Phase 6.4: advance per-entity animation playback before drawing
|
||||
|
|
@ -2870,6 +2941,21 @@ public sealed class GameWindow : IDisposable
|
|||
if (_animatedEntities.Count > 0)
|
||||
TickAnimations((float)deltaSeconds);
|
||||
|
||||
// Phase G.1: weather state machine — deterministic per-day roll
|
||||
// + transitions + lightning flash.
|
||||
var cal = WorldTime.CurrentCalendar;
|
||||
int dayIndex = cal.Year * (AcDream.Core.World.DerethDateTime.DaysInAMonth *
|
||||
AcDream.Core.World.DerethDateTime.MonthsInAYear)
|
||||
+ (int)cal.Month * AcDream.Core.World.DerethDateTime.DaysInAMonth
|
||||
+ (cal.Day - 1);
|
||||
Weather.Tick(nowSeconds: _weatherAccum, dayIndex: dayIndex, dtSeconds: (float)deltaSeconds);
|
||||
_weatherAccum += deltaSeconds;
|
||||
|
||||
// Update the rain/snow particle emitters when the weather kind
|
||||
// changes. Keep the emitters fed by the ParticleSystem tick so
|
||||
// visuals stay alive frame-over-frame.
|
||||
UpdateWeatherParticles(atmo);
|
||||
|
||||
// Phase E.3: advance live particle emitters AFTER animation tick
|
||||
// so emitters spawned by hooks fired this frame get integrated.
|
||||
_particleSystem?.Tick((float)deltaSeconds);
|
||||
|
|
@ -2882,26 +2968,14 @@ public sealed class GameWindow : IDisposable
|
|||
var camera = _cameraController.Active;
|
||||
var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * camera.Projection);
|
||||
|
||||
// Never cull the landblock the player is currently on.
|
||||
uint? playerLb = null;
|
||||
if (_playerMode && _playerController is not null)
|
||||
{
|
||||
var pp = _playerController.Position;
|
||||
int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f);
|
||||
int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f);
|
||||
playerLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF);
|
||||
}
|
||||
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
|
||||
|
||||
// Step 4: portal visibility — determine which interior cells to render.
|
||||
// Extract camera world position from the inverse of the view matrix.
|
||||
// Extract camera world position from the inverse of the view
|
||||
// matrix — needed by the scene-lighting UBO (for fog distance)
|
||||
// and by the sky renderer (for the camera-centered sky dome).
|
||||
System.Numerics.Matrix4x4.Invert(camera.View, out var invView);
|
||||
var camPos = new System.Numerics.Vector3(invView.M41, invView.M42, invView.M43);
|
||||
|
||||
// Phase E.2 audio: update listener pose so 3D sounds pan/attenuate
|
||||
// correctly relative to where we're looking. Fwd = -Z of the view
|
||||
// matrix (OpenGL convention), up = +Y. Both live in the inverse
|
||||
// view matrix's basis vectors.
|
||||
// correctly relative to where we're looking.
|
||||
if (_audioEngine is not null && _audioEngine.IsAvailable)
|
||||
{
|
||||
var fwd = new System.Numerics.Vector3(-invView.M31, -invView.M32, -invView.M33);
|
||||
|
|
@ -2912,9 +2986,44 @@ public sealed class GameWindow : IDisposable
|
|||
up.X, up.Y, up.Z);
|
||||
}
|
||||
|
||||
// Step 4: portal visibility — compute BEFORE the UBO upload so
|
||||
// the indoor flag drives the sun's intensity to zero for
|
||||
// dungeons (r13 §13.7).
|
||||
var visibility = _cellVisibility.ComputeVisibility(camPos);
|
||||
bool cameraInsideCell = visibility?.CameraCell is not null;
|
||||
|
||||
// Phase G.1/G.2: feed the sun, tick LightManager, build + upload
|
||||
// the scene-lighting UBO once per frame. Every shader that
|
||||
// consumes binding=1 reads the same data for the rest of the
|
||||
// frame — terrain, static mesh, instanced mesh, sky.
|
||||
UpdateSunFromSky(kf, cameraInsideCell);
|
||||
Lighting.Tick(camPos);
|
||||
var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build(
|
||||
Lighting, in atmo, camPos, (float)WorldTime.DayFraction);
|
||||
_sceneLightingUbo?.Upload(ubo);
|
||||
|
||||
// Never cull the landblock the player is currently on.
|
||||
uint? playerLb = null;
|
||||
if (_playerMode && _playerController is not null)
|
||||
{
|
||||
var pp = _playerController.Position;
|
||||
int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f);
|
||||
int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f);
|
||||
playerLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF);
|
||||
}
|
||||
|
||||
// Phase G.1: sky renderer — draws the far-plane-infinity
|
||||
// celestial meshes FIRST so the rest of the scene z-tests
|
||||
// on top of them (depth mask off, no depth writes). Skipped
|
||||
// when indoors; dungeons fully block sky visibility.
|
||||
if (!cameraInsideCell)
|
||||
{
|
||||
_skyRenderer?.Render(camera, camPos, (float)WorldTime.DayFraction,
|
||||
_loadedSkyDesc?.DefaultDayGroup);
|
||||
}
|
||||
|
||||
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
|
||||
|
||||
// Conditional depth clear: when camera is inside a building, clear
|
||||
// depth (not color) so interior geometry writes fresh Z values on top
|
||||
// of the terrain color buffer. Exit portals show outdoor terrain color
|
||||
|
|
@ -3409,6 +3518,163 @@ public sealed class GameWindow : IDisposable
|
|||
ae.CurrFrame = ae.LowFrame;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derive the current sun (directional light, slot 0 of the UBO)
|
||||
/// from the interpolated <see cref="AcDream.Core.World.SkyKeyframe"/>,
|
||||
/// plus the cell ambient. Indoor cells force the sun intensity to
|
||||
/// zero (r13 §13.7) and substitute a fixed dungeon-tone ambient.
|
||||
/// </summary>
|
||||
private void UpdateSunFromSky(AcDream.Core.World.SkyKeyframe kf, bool cameraInsideCell)
|
||||
{
|
||||
// Sun direction: points FROM the sun TOWARDS the world. Our
|
||||
// shader does dot(N, -forward) so a positive N·L means the
|
||||
// surface faces the sun.
|
||||
var sunToWorld = -AcDream.Core.World.SkyStateProvider.SunDirectionFromKeyframe(kf);
|
||||
|
||||
if (cameraInsideCell)
|
||||
{
|
||||
// Dungeon default per r13 §3 — warm-dark ambient, no sun.
|
||||
Lighting.Sun = new AcDream.Core.Lighting.LightSource
|
||||
{
|
||||
Kind = AcDream.Core.Lighting.LightKind.Directional,
|
||||
WorldForward = sunToWorld,
|
||||
ColorLinear = System.Numerics.Vector3.Zero,
|
||||
Intensity = 0f,
|
||||
Range = 1f,
|
||||
};
|
||||
Lighting.CurrentAmbient = new AcDream.Core.Lighting.CellAmbientState(
|
||||
AmbientColor: new System.Numerics.Vector3(0.10f, 0.09f, 0.08f),
|
||||
SunColor: System.Numerics.Vector3.Zero,
|
||||
SunDirection: sunToWorld);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Outdoor: full keyframe sun + ambient; colors are already
|
||||
// pre-multiplied by DirBright / AmbBright inside
|
||||
// SkyDescLoader so we feed them straight into the UBO.
|
||||
Lighting.Sun = new AcDream.Core.Lighting.LightSource
|
||||
{
|
||||
Kind = AcDream.Core.Lighting.LightKind.Directional,
|
||||
WorldForward = sunToWorld,
|
||||
ColorLinear = kf.SunColor,
|
||||
Intensity = 1f,
|
||||
Range = 1f,
|
||||
};
|
||||
Lighting.CurrentAmbient = new AcDream.Core.Lighting.CellAmbientState(
|
||||
AmbientColor: kf.AmbientColor,
|
||||
SunColor: kf.SunColor,
|
||||
SunDirection: sunToWorld);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keep the rain/snow camera-anchored emitters aligned with the
|
||||
/// current weather state. Spawns on entry, stops on exit, with no
|
||||
/// per-frame churn while the state is stable. Emitters are camera-
|
||||
/// local (<see cref="AcDream.Core.Vfx.EmitterFlags.AttachLocal"/>)
|
||||
/// so walking never leaves the rain volume (r12 §7).
|
||||
/// </summary>
|
||||
private void UpdateWeatherParticles(in AcDream.Core.World.AtmosphereSnapshot atmo)
|
||||
{
|
||||
if (_particleSystem is null) return;
|
||||
|
||||
if (atmo.Kind == _lastWeatherKind) return; // no change
|
||||
|
||||
// Stop any existing emitters first.
|
||||
if (_rainEmitterHandle != 0)
|
||||
{
|
||||
_particleSystem.StopEmitter(_rainEmitterHandle, fadeOut: true);
|
||||
_rainEmitterHandle = 0;
|
||||
}
|
||||
if (_snowEmitterHandle != 0)
|
||||
{
|
||||
_particleSystem.StopEmitter(_snowEmitterHandle, fadeOut: true);
|
||||
_snowEmitterHandle = 0;
|
||||
}
|
||||
|
||||
// Anchor at camera world position; AttachLocal keeps it moving.
|
||||
var anchor = System.Numerics.Vector3.Zero;
|
||||
if (_cameraController is not null)
|
||||
{
|
||||
System.Numerics.Matrix4x4.Invert(_cameraController.Active.View, out var inv);
|
||||
anchor = new System.Numerics.Vector3(inv.M41, inv.M42, inv.M43);
|
||||
}
|
||||
|
||||
switch (atmo.Kind)
|
||||
{
|
||||
case AcDream.Core.World.WeatherKind.Rain:
|
||||
case AcDream.Core.World.WeatherKind.Storm:
|
||||
_rainEmitterHandle = _particleSystem.SpawnEmitter(
|
||||
BuildRainDesc(), anchor);
|
||||
break;
|
||||
case AcDream.Core.World.WeatherKind.Snow:
|
||||
_snowEmitterHandle = _particleSystem.SpawnEmitter(
|
||||
BuildSnowDesc(), anchor);
|
||||
break;
|
||||
}
|
||||
|
||||
_lastWeatherKind = atmo.Kind;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rain emitter tuned per r12 §7: streaks falling at ~50 m/s with
|
||||
/// a slight wind bias, 500 drops/sec, 2000 max alive, 1.2s life so
|
||||
/// drops cover the ~60m fall at terminal velocity.
|
||||
/// </summary>
|
||||
private static AcDream.Core.Vfx.EmitterDesc BuildRainDesc() => new()
|
||||
{
|
||||
DatId = 0xFFFF_0001u, // synthetic id
|
||||
Type = AcDream.Core.Vfx.ParticleType.LocalVelocity,
|
||||
Flags = AcDream.Core.Vfx.EmitterFlags.AttachLocal |
|
||||
AcDream.Core.Vfx.EmitterFlags.Billboard,
|
||||
EmitRate = 500f,
|
||||
MaxParticles = 2000,
|
||||
LifetimeMin = 1.0f,
|
||||
LifetimeMax = 1.4f,
|
||||
OffsetDir = new System.Numerics.Vector3(0, 0, 1),
|
||||
MinOffset = 0f,
|
||||
MaxOffset = 50f,
|
||||
SpawnDiskRadius = 15f,
|
||||
InitialVelocity = new System.Numerics.Vector3(0.5f, 0f, -50f),
|
||||
VelocityJitter = 2f,
|
||||
Gravity = System.Numerics.Vector3.Zero,
|
||||
StartColorArgb = 0x40B0C0E0u,
|
||||
EndColorArgb = 0x20B0C0E0u,
|
||||
StartAlpha = 0.3f,
|
||||
EndAlpha = 0f,
|
||||
StartSize = 0.05f,
|
||||
EndSize = 0.05f,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Snow emitter tuned per r12 §8: slow fall at ~2 m/s, tumbling
|
||||
/// sideways drift, small billboards, 100 flakes/sec, long lifespan.
|
||||
/// </summary>
|
||||
private static AcDream.Core.Vfx.EmitterDesc BuildSnowDesc() => new()
|
||||
{
|
||||
DatId = 0xFFFF_0002u,
|
||||
Type = AcDream.Core.Vfx.ParticleType.LocalVelocity,
|
||||
Flags = AcDream.Core.Vfx.EmitterFlags.AttachLocal |
|
||||
AcDream.Core.Vfx.EmitterFlags.Billboard,
|
||||
EmitRate = 100f,
|
||||
MaxParticles = 1000,
|
||||
LifetimeMin = 4f,
|
||||
LifetimeMax = 8f,
|
||||
OffsetDir = new System.Numerics.Vector3(0, 0, 1),
|
||||
MinOffset = 0f,
|
||||
MaxOffset = 30f,
|
||||
SpawnDiskRadius = 15f,
|
||||
InitialVelocity = new System.Numerics.Vector3(0.3f, 0.2f, -2f),
|
||||
VelocityJitter = 0.8f,
|
||||
Gravity = System.Numerics.Vector3.Zero,
|
||||
StartColorArgb = 0xE0FFFFFFu,
|
||||
EndColorArgb = 0x80FFFFFFu,
|
||||
StartAlpha = 0.85f,
|
||||
EndAlpha = 0.3f,
|
||||
StartSize = 0.08f,
|
||||
EndSize = 0.06f,
|
||||
};
|
||||
|
||||
private void OnClosing()
|
||||
{
|
||||
// Phase A.1: join the streamer worker thread before tearing down GL
|
||||
|
|
@ -3422,6 +3688,8 @@ public sealed class GameWindow : IDisposable
|
|||
_meshShader?.Dispose();
|
||||
_terrain?.Dispose();
|
||||
_shader?.Dispose();
|
||||
_sceneLightingUbo?.Dispose();
|
||||
_skyRenderer?.Dispose();
|
||||
_debugLines?.Dispose();
|
||||
_textRenderer?.Dispose();
|
||||
_debugFont?.Dispose();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue