merge: sky/weather/lighting overhaul branch (Opus agent, 7 commits, +27 tests)
Ships full retail-faithful sky-object rendering, 5-kind weather with deterministic per-day roll + storm lightning, dynamic-lighting shader UBO with retail hard-cutoff semantics, per-entity torch LightSource registration via Setup.Lights, ParticleRenderer for rain/snow, and TimeSync handshake wiring. F7 / F10 debug keys for time/weather cycling. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
48b5e1f1b1
31 changed files with 3057 additions and 129 deletions
|
|
@ -101,7 +101,14 @@ public sealed class DebugOverlay
|
|||
int StreamingRadius,
|
||||
float MouseSensitivity,
|
||||
float ChaseDistance,
|
||||
bool RmbOrbit);
|
||||
bool RmbOrbit,
|
||||
// Phase G.1/G.2 — sky + weather + lighting
|
||||
string HourName = "",
|
||||
float DayFraction = 0f,
|
||||
string Weather = "Clear",
|
||||
int ActiveLights = 0,
|
||||
int RegisteredLights = 0,
|
||||
int ParticleCount = 0);
|
||||
|
||||
public DebugOverlay(TextRenderer text, BitmapFont font)
|
||||
{
|
||||
|
|
@ -205,6 +212,10 @@ public sealed class DebugOverlay
|
|||
($"lb {s.LandblocksVisible,3}/{s.LandblocksTotal,3} visible", White),
|
||||
($"ent {s.EntityCount,4} anim {s.AnimatedCount,3}", White),
|
||||
($"coll {s.ShadowObjectCount,5} radius {s.StreamingRadius}", White),
|
||||
// Phase G: sky + weather + dynamic lighting surface.
|
||||
($"time {s.DayFraction,5:F2} {s.HourName}", Cyan),
|
||||
($"wx {s.Weather,-8} parts {s.ParticleCount,5}", Cyan),
|
||||
($"lit {s.ActiveLights}/{s.RegisteredLights} ", Cyan),
|
||||
};
|
||||
|
||||
float pad = 10f;
|
||||
|
|
@ -277,6 +288,8 @@ public sealed class DebugOverlay
|
|||
("F4", "toggle debug HUD info panel"),
|
||||
("F5", "toggle stats panel"),
|
||||
("F6", "toggle compass"),
|
||||
("F7", "cycle time-of-day override (none/midnight/dawn/noon/dusk)"),
|
||||
("F10", "cycle weather (clear/overcast/rain/snow/storm)"),
|
||||
("F", "toggle fly camera"),
|
||||
("Tab", "toggle player mode (requires login)"),
|
||||
("W A S D", "move (player mode) / fly"),
|
||||
|
|
@ -388,7 +401,7 @@ public sealed class DebugOverlay
|
|||
|
||||
private void DrawHintBar(Vector2 screenSize)
|
||||
{
|
||||
string hint = "F1 help F2 wireframes F3 dump F4/F5/F6 panels F8/F9 sens Tab player Hold RMB orbit Wheel zoom";
|
||||
string hint = "F1 help F2 wires F3 dump F4/F5/F6 panels F7 time F8/F9 sens F10 weather Tab player RMB orbit Wheel zoom";
|
||||
float w = _font.MeasureWidth(hint);
|
||||
float pad = 10f;
|
||||
float y = screenSize.Y - _font.LineHeight - pad;
|
||||
|
|
|
|||
|
|
@ -140,6 +140,7 @@ public sealed class GameWindow : IDisposable
|
|||
private readonly AcDream.Core.Vfx.EmitterDescRegistry _emitterRegistry = new();
|
||||
private AcDream.Core.Vfx.ParticleSystem? _particleSystem;
|
||||
private AcDream.Core.Vfx.ParticleHookSink? _particleSink;
|
||||
private AcDream.App.Rendering.ParticleRenderer? _particleRenderer;
|
||||
|
||||
// Remote-entity motion inference: tracks when each remote entity last
|
||||
// moved meaningfully. Used in TickAnimations to swap to Ready when
|
||||
|
|
@ -240,6 +241,32 @@ 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();
|
||||
// Wired into the hook router in OnLoad so SetLightHook fires
|
||||
// from the animation pipeline flip the matching LightSource.IsLit.
|
||||
private AcDream.Core.Lighting.LightingHookSink? _lightingSink;
|
||||
|
||||
// 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;
|
||||
|
||||
// F7 / F10 debug-cycle steps for time + weather. Initialized out of
|
||||
// range of the real values so the first press hits index 0 of the
|
||||
// cycle table cleanly.
|
||||
private int _timeDebugStep = 0;
|
||||
private int _weatherDebugStep = 0;
|
||||
|
||||
// Phase B.2: player movement mode.
|
||||
private AcDream.App.Input.PlayerMovementController? _playerController;
|
||||
|
|
@ -428,6 +455,51 @@ public sealed class GameWindow : IDisposable
|
|||
_debugOverlay.Toast($"Compass {(_debugOverlay.ShowCompass ? "ON" : "OFF")}");
|
||||
}
|
||||
}
|
||||
else if (key == Key.F7)
|
||||
{
|
||||
// Phase G.1: cycle debug time-of-day overrides. Useful for
|
||||
// visually verifying the sun arc + keyframe transitions
|
||||
// without waiting 30+ real-time hours. Cycle order:
|
||||
// clear debug → 0.0 (midnight) → 0.25 (dawn)
|
||||
// → 0.5 (noon) → 0.75 (dusk) → clear
|
||||
_timeDebugStep = (_timeDebugStep + 1) % 5;
|
||||
float? pick = _timeDebugStep switch
|
||||
{
|
||||
0 => (float?)null, // server time
|
||||
1 => 0.0f,
|
||||
2 => 0.25f,
|
||||
3 => 0.5f,
|
||||
4 => 0.75f,
|
||||
_ => null,
|
||||
};
|
||||
if (pick.HasValue)
|
||||
{
|
||||
WorldTime.SetDebugTime(pick.Value);
|
||||
_debugOverlay?.Toast($"Time override = {pick.Value:F2}");
|
||||
}
|
||||
else
|
||||
{
|
||||
WorldTime.ClearDebugTime();
|
||||
_debugOverlay?.Toast("Time override cleared");
|
||||
}
|
||||
}
|
||||
else if (key == Key.F10)
|
||||
{
|
||||
// Phase G.1: cycle weather kinds manually. Useful for
|
||||
// testing the rain/snow particle systems + storm/light
|
||||
// fog without waiting for the daily RNG to hit.
|
||||
var kinds = new[]
|
||||
{
|
||||
AcDream.Core.World.WeatherKind.Clear,
|
||||
AcDream.Core.World.WeatherKind.Overcast,
|
||||
AcDream.Core.World.WeatherKind.Rain,
|
||||
AcDream.Core.World.WeatherKind.Snow,
|
||||
AcDream.Core.World.WeatherKind.Storm,
|
||||
};
|
||||
_weatherDebugStep = (_weatherDebugStep + 1) % kinds.Length;
|
||||
Weather.ForceWeather(kinds[_weatherDebugStep]);
|
||||
_debugOverlay?.Toast($"Weather = {kinds[_weatherDebugStep]}");
|
||||
}
|
||||
else if (key == Key.F8 || key == Key.F9)
|
||||
{
|
||||
// Adjust whichever mode's sensitivity is currently active.
|
||||
|
|
@ -645,6 +717,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.
|
||||
|
|
@ -684,6 +762,12 @@ public sealed class GameWindow : IDisposable
|
|||
_particleSink = new AcDream.Core.Vfx.ParticleHookSink(_particleSystem);
|
||||
_hookRouter.Register(_particleSink);
|
||||
|
||||
// Phase G.2 lighting hooks: SetLightHook flips IsLit on
|
||||
// owner-tagged lights so ignite-torch animations light up,
|
||||
// extinguish-torch animations go dark.
|
||||
_lightingSink = new AcDream.Core.Lighting.LightingHookSink(Lighting);
|
||||
_hookRouter.Register(_lightingSink);
|
||||
|
||||
// Phase E.2 audio: init OpenAL + hook sink. Suppressible via
|
||||
// ACDREAM_NO_AUDIO=1 for headless tests / broken audio drivers.
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_NO_AUDIO") != "1")
|
||||
|
|
@ -719,6 +803,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);
|
||||
|
||||
|
|
@ -755,6 +868,21 @@ 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 G.1 particle renderer — renders rain / snow / spell auras
|
||||
// spawned into the shared ParticleSystem as billboard quads.
|
||||
// Weather uses AttachLocal emitters so the rain volume follows
|
||||
// the player.
|
||||
_particleRenderer = new ParticleRenderer(_gl, shadersDir);
|
||||
|
||||
// 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.
|
||||
|
|
@ -778,6 +906,16 @@ public sealed class GameWindow : IDisposable
|
|||
radius: _streamingRadius,
|
||||
removeTerrain: id =>
|
||||
{
|
||||
// Phase G.2: release any LightSources attached to entities
|
||||
// in this landblock before their records disappear from
|
||||
// _worldState — otherwise the LightManager accumulates
|
||||
// stale entries for every walk across a landblock boundary.
|
||||
if (_lightingSink is not null &&
|
||||
_worldState.TryGetLandblock(id, out var lb))
|
||||
{
|
||||
foreach (var ent in lb!.Entities)
|
||||
_lightingSink.UnregisterOwner(ent.Id);
|
||||
}
|
||||
_terrain?.RemoveLandblock(id);
|
||||
_physicsEngine.RemoveLandblock(id);
|
||||
_cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu);
|
||||
|
|
@ -816,6 +954,11 @@ public sealed class GameWindow : IDisposable
|
|||
_liveSession.PositionUpdated += OnLivePositionUpdated;
|
||||
_liveSession.TeleportStarted += OnTeleportStarted;
|
||||
|
||||
// Phase G.1: keep the client's day/night clock in sync with
|
||||
// server time. Fires once from ConnectRequest (initial seed)
|
||||
// and repeatedly on TimeSync-flagged packets.
|
||||
_liveSession.ServerTimeUpdated += ticks => WorldTime.SyncFromServer(ticks);
|
||||
|
||||
// Phase F.1-H.1: wire every parsed GameEvent into the right
|
||||
// Core state class (chat, combat, spellbook, items). After
|
||||
// this one call, server-sent ChannelBroadcast / damage
|
||||
|
|
@ -2446,6 +2589,32 @@ public sealed class GameWindow : IDisposable
|
|||
int scTried = 0, scHaveBounds = 0, scRegistered = 0, scTooThin = 0, scNoBounds = 0;
|
||||
foreach (var entity in lb.Entities)
|
||||
{
|
||||
// Phase G.2: if the entity's Setup has baked-in LightInfos,
|
||||
// register them with the LightManager so torches, braziers,
|
||||
// and lifestones cast real light on nearby geometry. Hooked
|
||||
// via the LightingHookSink so per-entity owner tracking +
|
||||
// SetLightHook IsLit toggles all go through one codepath.
|
||||
// Only applies to Setup-sourced entities (0x02xxxxxx) — raw
|
||||
// GfxObjs don't carry Lights dictionaries.
|
||||
if (_lightingSink is not null && _dats is not null)
|
||||
{
|
||||
uint src = entity.SourceGfxObjOrSetupId;
|
||||
if ((src & 0xFF000000u) == 0x02000000u)
|
||||
{
|
||||
var datSetup = _dats.Get<DatReaderWriter.DBObjs.Setup>(src);
|
||||
if (datSetup is not null && datSetup.Lights.Count > 0)
|
||||
{
|
||||
var loaded = AcDream.Core.Lighting.LightInfoLoader.Load(
|
||||
datSetup,
|
||||
ownerId: entity.Id,
|
||||
entityPosition: entity.Position,
|
||||
entityRotation: entity.Rotation);
|
||||
foreach (var ls in loaded)
|
||||
_lightingSink.RegisterOwnedLight(ls);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int entityBsp = 0, entityCyl = 0;
|
||||
// Treat both procedural scenery (0x80000000+) AND LandBlockInfo
|
||||
// stabs (IDs < 0x40000000 with 0x01/0x02 source) as outdoor-entities
|
||||
|
|
@ -3069,6 +3238,24 @@ 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;
|
||||
// Clamp to 0..1 — keyframes may store over-1 values (retail uses the
|
||||
// dir-bright scalar pre-multiplied into color) and GL's ClearColor
|
||||
// will silently accept them, but some drivers interpret > 1 as
|
||||
// "bright clamp", producing ugly pink/green frames.
|
||||
_gl!.ClearColor(
|
||||
System.Math.Clamp(fogColor.X, 0f, 1f),
|
||||
System.Math.Clamp(fogColor.Y, 0f, 1f),
|
||||
System.Math.Clamp(fogColor.Z, 0f, 1f),
|
||||
1f);
|
||||
|
||||
_gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
|
||||
|
||||
// Phase 6.4: advance per-entity animation playback before drawing
|
||||
|
|
@ -3076,6 +3263,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);
|
||||
|
|
@ -3088,26 +3290,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);
|
||||
|
|
@ -3118,9 +3308,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
|
||||
|
|
@ -3132,6 +3357,13 @@ public sealed class GameWindow : IDisposable
|
|||
neverCullLandblockId: playerLb,
|
||||
visibleCellIds: visibility?.VisibleCellIds);
|
||||
|
||||
// Phase G.1 / E.3: draw all live particles after opaque
|
||||
// scene geometry so alpha blending composites correctly.
|
||||
// Runs with depth test on (particles occluded by walls)
|
||||
// but depth write off (no self-occlusion sorting needed).
|
||||
if (_particleSystem is not null && _particleRenderer is not null)
|
||||
_particleRenderer.Draw(_particleSystem, camera, camPos);
|
||||
|
||||
// Debug: draw collision shapes as wireframe cylinders around the
|
||||
// player so we can visually verify alignment with scenery meshes.
|
||||
if (_debugCollisionVisible && _debugLines is not null)
|
||||
|
|
@ -3276,6 +3508,8 @@ public sealed class GameWindow : IDisposable
|
|||
else
|
||||
activeSens = _sensOrbit;
|
||||
|
||||
// Phase G: pull sky + weather + lighting state for the overlay.
|
||||
var dayCal = WorldTime.CurrentCalendar;
|
||||
var snapshot = new DebugOverlay.Snapshot(
|
||||
Fps: (float)_lastFps,
|
||||
FrameTimeMs: (float)_lastFrameMs,
|
||||
|
|
@ -3298,7 +3532,13 @@ public sealed class GameWindow : IDisposable
|
|||
StreamingRadius: _streamingRadius,
|
||||
MouseSensitivity: activeSens,
|
||||
ChaseDistance: _chaseCamera?.Distance ?? 0f,
|
||||
RmbOrbit: _rmbHeld);
|
||||
RmbOrbit: _rmbHeld,
|
||||
HourName: dayCal.Hour.ToString(),
|
||||
DayFraction: (float)WorldTime.DayFraction,
|
||||
Weather: Weather.Kind.ToString(),
|
||||
ActiveLights: Lighting.ActiveCount,
|
||||
RegisteredLights: Lighting.RegisteredCount,
|
||||
ParticleCount: _particleSystem?.ActiveParticleCount ?? 0);
|
||||
|
||||
_debugOverlay.Update((float)deltaSeconds);
|
||||
var size = new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y);
|
||||
|
|
@ -3790,6 +4030,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
|
||||
|
|
@ -3803,6 +4200,9 @@ public sealed class GameWindow : IDisposable
|
|||
_meshShader?.Dispose();
|
||||
_terrain?.Dispose();
|
||||
_shader?.Dispose();
|
||||
_sceneLightingUbo?.Dispose();
|
||||
_skyRenderer?.Dispose();
|
||||
_particleRenderer?.Dispose();
|
||||
_debugLines?.Dispose();
|
||||
_textRenderer?.Dispose();
|
||||
_debugFont?.Dispose();
|
||||
|
|
|
|||
|
|
@ -159,10 +159,10 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
|
|||
var vp = camera.View * camera.Projection;
|
||||
_shader.SetMatrix4("uViewProjection", vp);
|
||||
|
||||
// Lighting uniforms matching ACME StaticObject.vert.
|
||||
var lightDir = Vector3.Normalize(new Vector3(0.5f, 0.3f, -0.3f));
|
||||
_shader.SetVec3("uLightDirection", lightDir);
|
||||
_shader.SetFloat("uAmbientIntensity", 0.45f);
|
||||
// Phase G: lighting + ambient + fog are owned by the
|
||||
// SceneLighting UBO (binding=1) uploaded once per frame by
|
||||
// GameWindow. The instanced mesh fragment shader reads it
|
||||
// directly — no per-draw uniform uploads needed.
|
||||
|
||||
// ── Collect and group instances ───────────────────────────────────────
|
||||
CollectGroups(landblockEntries, frustum, neverCullLandblockId, visibleCellIds);
|
||||
|
|
|
|||
219
src/AcDream.App/Rendering/ParticleRenderer.cs
Normal file
219
src/AcDream.App/Rendering/ParticleRenderer.cs
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Vfx;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Simple billboard-quad particle renderer. One draw call per emitter:
|
||||
/// the CPU streams (position, size, rotation, packed color) into a
|
||||
/// per-instance VBO; a unit quad VBO gets instanced and the vertex
|
||||
/// shader rotates the quad around the camera forward vector so it
|
||||
/// always faces the viewer.
|
||||
///
|
||||
/// <para>
|
||||
/// Not a retail-perfect port of the D3D7 fixed-function particle pipe;
|
||||
/// good enough for rain, snow, and the basic spell auras we need for
|
||||
/// Phase G.1's weather + E.3's playback. Trails + spot-light
|
||||
/// interactions deferred.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Emitters tagged with <see cref="EmitterFlags.AttachLocal"/> get
|
||||
/// re-anchored to the current camera position each frame so the rain
|
||||
/// volume follows the player (r12 §7). This is the cheap version of
|
||||
/// retail's "IsParentLocal" flag on held emitters.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed unsafe class ParticleRenderer : IDisposable
|
||||
{
|
||||
private readonly GL _gl;
|
||||
private readonly Shader _shader;
|
||||
|
||||
// Unit-quad vertex buffer (-0.5..+0.5 in XY). 4 verts, 6 indices.
|
||||
private readonly uint _quadVao;
|
||||
private readonly uint _quadVbo;
|
||||
private readonly uint _quadEbo;
|
||||
|
||||
// Instance buffer — 8 floats per particle: posX,Y,Z, size, colorR,G,B,A.
|
||||
private readonly uint _instanceVbo;
|
||||
private float[] _instanceScratch = new float[256 * 8];
|
||||
|
||||
public ParticleRenderer(GL gl, string shadersDir)
|
||||
{
|
||||
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
||||
_shader = new Shader(_gl,
|
||||
System.IO.Path.Combine(shadersDir, "particle.vert"),
|
||||
System.IO.Path.Combine(shadersDir, "particle.frag"));
|
||||
|
||||
// Unit quad around origin (XY plane, Z = 0). The vertex shader
|
||||
// reads this, then offsets into world space using the
|
||||
// per-instance (pos, size) values.
|
||||
float[] quadVerts = new float[]
|
||||
{
|
||||
// pos x,y uv
|
||||
-0.5f, -0.5f, 0f, 0f,
|
||||
0.5f, -0.5f, 1f, 0f,
|
||||
0.5f, 0.5f, 1f, 1f,
|
||||
-0.5f, 0.5f, 0f, 1f,
|
||||
};
|
||||
uint[] quadIdx = new uint[] { 0, 1, 2, 0, 2, 3 };
|
||||
|
||||
_quadVao = _gl.GenVertexArray();
|
||||
_gl.BindVertexArray(_quadVao);
|
||||
|
||||
_quadVbo = _gl.GenBuffer();
|
||||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _quadVbo);
|
||||
fixed (void* p = quadVerts)
|
||||
_gl.BufferData(BufferTargetARB.ArrayBuffer,
|
||||
(nuint)(quadVerts.Length * sizeof(float)), p, BufferUsageARB.StaticDraw);
|
||||
_gl.EnableVertexAttribArray(0);
|
||||
_gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, 4 * sizeof(float), (void*)0);
|
||||
_gl.EnableVertexAttribArray(1);
|
||||
_gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, 4 * sizeof(float), (void*)(2 * sizeof(float)));
|
||||
|
||||
_quadEbo = _gl.GenBuffer();
|
||||
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _quadEbo);
|
||||
fixed (void* p = quadIdx)
|
||||
_gl.BufferData(BufferTargetARB.ElementArrayBuffer,
|
||||
(nuint)(quadIdx.Length * sizeof(uint)), p, BufferUsageARB.StaticDraw);
|
||||
|
||||
_instanceVbo = _gl.GenBuffer();
|
||||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
|
||||
_gl.BufferData(BufferTargetARB.ArrayBuffer, (nuint)(256 * 8 * sizeof(float)),
|
||||
(void*)0, BufferUsageARB.DynamicDraw);
|
||||
|
||||
// Per-instance attributes: pos+size at loc 2, color at loc 3.
|
||||
_gl.EnableVertexAttribArray(2);
|
||||
_gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, 8 * sizeof(float), (void*)0);
|
||||
_gl.VertexAttribDivisor(2, 1);
|
||||
_gl.EnableVertexAttribArray(3);
|
||||
_gl.VertexAttribPointer(3, 4, VertexAttribPointerType.Float, false, 8 * sizeof(float), (void*)(4 * sizeof(float)));
|
||||
_gl.VertexAttribDivisor(3, 1);
|
||||
|
||||
_gl.BindVertexArray(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draw every live particle. Splits emitters by blend mode (additive
|
||||
/// vs alpha-blend) but doesn't sort by depth — particles don't
|
||||
/// self-occlude enough for sorting to matter for rain/snow.
|
||||
/// </summary>
|
||||
public void Draw(ParticleSystem particles, ICamera camera, Vector3 cameraWorldPos)
|
||||
{
|
||||
if (particles is null || camera is null) return;
|
||||
|
||||
_shader.Use();
|
||||
_shader.SetMatrix4("uViewProjection", camera.View * camera.Projection);
|
||||
_shader.SetVec3("uCameraRight", GetCameraRight(camera));
|
||||
_shader.SetVec3("uCameraUp", GetCameraUp(camera));
|
||||
|
||||
_gl.Enable(EnableCap.Blend);
|
||||
_gl.DepthMask(false);
|
||||
_gl.Disable(EnableCap.CullFace);
|
||||
|
||||
// Group emitters by additive vs alpha-blend so we flip blend state
|
||||
// once per group rather than per-emitter. Simple two-pass split.
|
||||
var alphaGroup = new List<ParticleEmitter>(32);
|
||||
var addGroup = new List<ParticleEmitter>(32);
|
||||
foreach (var (em, _) in particles.EnumerateLive())
|
||||
{
|
||||
var list = (em.Desc.Flags & EmitterFlags.Additive) != 0 ? addGroup : alphaGroup;
|
||||
if (list.Count == 0 || !ReferenceEquals(list[^1], em))
|
||||
list.Add(em);
|
||||
}
|
||||
|
||||
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
|
||||
foreach (var em in alphaGroup)
|
||||
DrawEmitter(em, cameraWorldPos);
|
||||
|
||||
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One);
|
||||
foreach (var em in addGroup)
|
||||
DrawEmitter(em, cameraWorldPos);
|
||||
|
||||
_gl.DepthMask(true);
|
||||
_gl.Disable(EnableCap.Blend);
|
||||
_gl.BindVertexArray(0);
|
||||
}
|
||||
|
||||
private void DrawEmitter(ParticleEmitter em, Vector3 cameraWorldPos)
|
||||
{
|
||||
int liveCount = 0;
|
||||
for (int i = 0; i < em.Particles.Length; i++)
|
||||
if (em.Particles[i].Alive) liveCount++;
|
||||
if (liveCount == 0) return;
|
||||
|
||||
// Ensure instance buffer is big enough.
|
||||
int needed = liveCount * 8;
|
||||
if (_instanceScratch.Length < needed)
|
||||
_instanceScratch = new float[needed + 256 * 8];
|
||||
|
||||
// Anchor adjustment for AttachLocal emitters — re-center the
|
||||
// emission volume on the camera each frame so the rain/snow
|
||||
// follows the viewer. The emitter's AnchorPos stays at the
|
||||
// spawn point, but when writing out world-space particles we
|
||||
// add (camera - emitterAnchor) so they track the camera.
|
||||
bool attachLocal = (em.Desc.Flags & EmitterFlags.AttachLocal) != 0;
|
||||
Vector3 cameraOffset = attachLocal ? (cameraWorldPos - em.AnchorPos) : Vector3.Zero;
|
||||
|
||||
int idx = 0;
|
||||
for (int i = 0; i < em.Particles.Length; i++)
|
||||
{
|
||||
ref var p = ref em.Particles[i];
|
||||
if (!p.Alive) continue;
|
||||
|
||||
Vector3 pos = p.Position + cameraOffset;
|
||||
_instanceScratch[idx * 8 + 0] = pos.X;
|
||||
_instanceScratch[idx * 8 + 1] = pos.Y;
|
||||
_instanceScratch[idx * 8 + 2] = pos.Z;
|
||||
_instanceScratch[idx * 8 + 3] = p.Size;
|
||||
|
||||
// ARGB → RGBA floats.
|
||||
float a = ((p.ColorArgb >> 24) & 0xFF) / 255f;
|
||||
float r = ((p.ColorArgb >> 16) & 0xFF) / 255f;
|
||||
float g = ((p.ColorArgb >> 8) & 0xFF) / 255f;
|
||||
float b = ( p.ColorArgb & 0xFF) / 255f;
|
||||
_instanceScratch[idx * 8 + 4] = r;
|
||||
_instanceScratch[idx * 8 + 5] = g;
|
||||
_instanceScratch[idx * 8 + 6] = b;
|
||||
_instanceScratch[idx * 8 + 7] = a;
|
||||
|
||||
idx++;
|
||||
}
|
||||
|
||||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
|
||||
fixed (void* bp = _instanceScratch)
|
||||
{
|
||||
_gl.BufferData(BufferTargetARB.ArrayBuffer,
|
||||
(nuint)(liveCount * 8 * sizeof(float)),
|
||||
bp, BufferUsageARB.DynamicDraw);
|
||||
}
|
||||
|
||||
_gl.BindVertexArray(_quadVao);
|
||||
_gl.DrawElementsInstanced(PrimitiveType.Triangles, 6,
|
||||
DrawElementsType.UnsignedInt, (void*)0, (uint)liveCount);
|
||||
}
|
||||
|
||||
private static Vector3 GetCameraRight(ICamera camera)
|
||||
{
|
||||
Matrix4x4.Invert(camera.View, out var inv);
|
||||
return Vector3.Normalize(new Vector3(inv.M11, inv.M12, inv.M13));
|
||||
}
|
||||
|
||||
private static Vector3 GetCameraUp(ICamera camera)
|
||||
{
|
||||
Matrix4x4.Invert(camera.View, out var inv);
|
||||
return Vector3.Normalize(new Vector3(inv.M21, inv.M22, inv.M23));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_gl.DeleteBuffer(_quadVbo);
|
||||
_gl.DeleteBuffer(_quadEbo);
|
||||
_gl.DeleteBuffer(_instanceVbo);
|
||||
_gl.DeleteVertexArray(_quadVao);
|
||||
_shader.Dispose();
|
||||
}
|
||||
}
|
||||
67
src/AcDream.App/Rendering/SceneLightingUboBinding.cs
Normal file
67
src/AcDream.App/Rendering/SceneLightingUboBinding.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using AcDream.Core.Lighting;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// GL wrapper that owns the SceneLighting UBO buffer, updates its
|
||||
/// contents each frame, and keeps it bound at binding=1 so every
|
||||
/// shader sampling <c>uLights[]</c> / <c>uFogColor</c> / etc reads
|
||||
/// consistent data without per-shader re-upload.
|
||||
///
|
||||
/// <para>
|
||||
/// Usage (r12 §13.2 + r13 §12.3):
|
||||
/// <list type="number">
|
||||
/// <item><description>Instantiate once at startup, after the GL context exists.</description></item>
|
||||
/// <item><description>Each frame, after <see cref="LightManager.Tick"/>, call <see cref="Upload"/> with a freshly-built <see cref="SceneLightingUbo"/>.</description></item>
|
||||
/// <item><description>Shader programs that declare <c>layout(std140, binding = 1) uniform SceneLighting { ... }</c> automatically pick up the data.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed unsafe class SceneLightingUboBinding : IDisposable
|
||||
{
|
||||
private readonly GL _gl;
|
||||
private readonly uint _ubo;
|
||||
private bool _disposed;
|
||||
|
||||
public SceneLightingUboBinding(GL gl)
|
||||
{
|
||||
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
||||
_ubo = _gl.GenBuffer();
|
||||
_gl.BindBuffer(BufferTargetARB.UniformBuffer, _ubo);
|
||||
// Pre-allocate with the final size; BufferSubData each frame.
|
||||
_gl.BufferData(
|
||||
BufferTargetARB.UniformBuffer,
|
||||
(nuint)SceneLightingUbo.SizeInBytes,
|
||||
(void*)0,
|
||||
BufferUsageARB.DynamicDraw);
|
||||
_gl.BindBuffer(BufferTargetARB.UniformBuffer, 0);
|
||||
|
||||
// Bind the buffer to the chosen binding point exactly once — shaders
|
||||
// that declare this binding in their layout block will read from it
|
||||
// on every draw without further intervention.
|
||||
_gl.BindBufferBase(BufferTargetARB.UniformBuffer,
|
||||
SceneLightingUbo.BindingPoint, _ubo);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Push the current frame's UBO contents to the GPU. Cheap (576 bytes)
|
||||
/// so fine to call unconditionally every frame.
|
||||
/// </summary>
|
||||
public void Upload(SceneLightingUbo data)
|
||||
{
|
||||
_gl.BindBuffer(BufferTargetARB.UniformBuffer, _ubo);
|
||||
_gl.BufferSubData(BufferTargetARB.UniformBuffer,
|
||||
(nint)0, (nuint)SceneLightingUbo.SizeInBytes, &data);
|
||||
_gl.BindBuffer(BufferTargetARB.UniformBuffer, 0);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_gl.DeleteBuffer(_ubo);
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
#version 430 core
|
||||
in vec2 vTex;
|
||||
in vec3 vWorldNormal;
|
||||
in vec3 vWorldPos;
|
||||
out vec4 fragColor;
|
||||
|
||||
uniform sampler2D uDiffuse;
|
||||
|
|
@ -11,35 +12,114 @@ uniform sampler2D uDiffuse;
|
|||
// 2 = AlphaBlend — GL blending handles compositing; do NOT discard
|
||||
// 3 = Additive — GL additive blending; do NOT discard
|
||||
// 4 = InvAlpha — GL inverted-alpha blending; do NOT discard
|
||||
//
|
||||
// Only ClipMap uses the alpha-discard path. AlphaBlend/Additive/InvAlpha
|
||||
// rely entirely on the GL blend stage — discarding low-alpha fragments
|
||||
// would make semi-transparent surfaces (portals, glows) fully invisible.
|
||||
uniform int uTranslucencyKind;
|
||||
|
||||
// Phase 3a: simple directional lighting. A single sun direction + ambient term
|
||||
// gives scenery and building faces enough differentiation to read as 3D instead
|
||||
// of looking like paper cutouts. Hardcoded for now; a later phase can route
|
||||
// light parameters through uniforms driven by the game's time-of-day.
|
||||
// Sun direction tuned after Phase 3a verification: (0.4,0.3,0.8) was too
|
||||
// vertical — roofs and ground both landed near peak brightness and only
|
||||
// walls dropped, so the contrast was hard to read through textures. More
|
||||
// oblique + lower ambient + higher diffuse = contrast ratio ~3.3x.
|
||||
const vec3 SUN_DIR = normalize(vec3(0.5, 0.4, 0.6));
|
||||
const float AMBIENT = 0.25;
|
||||
const float DIFFUSE = 0.75;
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Phase G.1+G.2: shared scene-lighting UBO (binding = 1).
|
||||
//
|
||||
// Layout mirrors SceneLightingUbo in C#:
|
||||
// struct Light {
|
||||
// vec4 posAndKind; xyz = world pos, w = kind (0=dir,1=point,2=spot)
|
||||
// vec4 dirAndRange; xyz = forward, w = range (metres, hard cutoff)
|
||||
// vec4 colorAndIntensity; xyz = RGB linear, w = intensity
|
||||
// vec4 coneAngleEtc; x = cone (rad), yzw = reserved
|
||||
// };
|
||||
// layout(std140, binding = 1) uniform SceneLighting {
|
||||
// Light uLights[8];
|
||||
// vec4 uCellAmbient; xyz = ambient RGB, w = active count
|
||||
// vec4 uFogParams; x = start, y = end, z = flash, w = mode
|
||||
// vec4 uFogColor; xyz = color
|
||||
// vec4 uCameraAndTime; xyz = camera pos, w = day fraction
|
||||
// };
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
struct Light {
|
||||
vec4 posAndKind;
|
||||
vec4 dirAndRange;
|
||||
vec4 colorAndIntensity;
|
||||
vec4 coneAngleEtc;
|
||||
};
|
||||
layout(std140, binding = 1) uniform SceneLighting {
|
||||
Light uLights[8];
|
||||
vec4 uCellAmbient;
|
||||
vec4 uFogParams;
|
||||
vec4 uFogColor;
|
||||
vec4 uCameraAndTime;
|
||||
};
|
||||
|
||||
// Retail hard-cutoff lighting equation (r13 §10.2). No distance
|
||||
// attenuation inside Range; hard edge at Range; spotlights use a
|
||||
// binary cos-cone test. This is deliberate — the retail "bubble of
|
||||
// light" look relies on crisp boundaries.
|
||||
vec3 accumulateLights(vec3 N, vec3 worldPos) {
|
||||
vec3 lit = uCellAmbient.xyz;
|
||||
int active = int(uCellAmbient.w);
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
if (i >= active) break;
|
||||
|
||||
int kind = int(uLights[i].posAndKind.w);
|
||||
vec3 Lcol = uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w;
|
||||
|
||||
if (kind == 0) {
|
||||
// Directional: "forward" is the light's direction vector
|
||||
// pointing INTO the scene. N·(-forward) = light-facing.
|
||||
vec3 Ldir = -uLights[i].dirAndRange.xyz;
|
||||
float ndl = max(0.0, dot(N, Ldir));
|
||||
lit += Lcol * ndl;
|
||||
} else {
|
||||
// Point / spot: falloff is a HARD bubble at Range.
|
||||
vec3 toL = uLights[i].posAndKind.xyz - worldPos;
|
||||
float d = length(toL);
|
||||
float range = uLights[i].dirAndRange.w;
|
||||
if (d < range && range > 1e-3) {
|
||||
vec3 Ldir = toL / max(d, 1e-4);
|
||||
float ndl = max(0.0, dot(N, Ldir));
|
||||
float atten = 1.0; // retail: no attenuation inside Range
|
||||
if (kind == 2) {
|
||||
// Spotlight: hard-edged cos-cone test.
|
||||
float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5);
|
||||
float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz);
|
||||
atten *= (cos_l > cos_edge) ? 1.0 : 0.0;
|
||||
}
|
||||
lit += Lcol * ndl * atten;
|
||||
}
|
||||
}
|
||||
}
|
||||
return lit;
|
||||
}
|
||||
|
||||
// Linear fog (r12 §5.1): mode 1 = LINEAR, 0 = off, others reserved.
|
||||
vec3 applyFog(vec3 lit, vec3 worldPos) {
|
||||
int mode = int(uFogParams.w);
|
||||
if (mode == 0) return lit;
|
||||
float d = length(worldPos - uCameraAndTime.xyz);
|
||||
float fogStart = uFogParams.x;
|
||||
float fogEnd = uFogParams.y;
|
||||
float span = max(1e-3, fogEnd - fogStart);
|
||||
float fog = clamp((d - fogStart) / span, 0.0, 1.0);
|
||||
return mix(lit, uFogColor.xyz, fog);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 sampled = texture(uDiffuse, vTex);
|
||||
|
||||
// Alpha cutout only for clip-map surfaces (doors, windows, vegetation).
|
||||
// Blended surface types (AlphaBlend, Additive, InvAlpha) must NOT
|
||||
// discard here — that would make every semi-transparent pixel invisible
|
||||
// before the blend stage even runs.
|
||||
if (uTranslucencyKind == 1 && sampled.a < 0.5) discard;
|
||||
|
||||
vec3 N = normalize(vWorldNormal);
|
||||
float ndotl = max(dot(N, SUN_DIR), 0.0);
|
||||
float lighting = AMBIENT + DIFFUSE * ndotl;
|
||||
fragColor = vec4(sampled.rgb * lighting, sampled.a);
|
||||
vec3 lit = accumulateLights(N, vWorldPos);
|
||||
|
||||
// Lightning flash (r12 §9) — additive cold-white pulse layered on top
|
||||
// of diffuse lighting.
|
||||
float flash = uFogParams.z;
|
||||
lit += flash * vec3(0.6, 0.6, 0.75);
|
||||
|
||||
// Clamp per-channel to 1.0 — matches retail (r13 §13.1).
|
||||
lit = min(lit, vec3(1.0));
|
||||
|
||||
vec3 rgb = sampled.rgb * lit;
|
||||
|
||||
// Atmospheric fog — applied after lighting.
|
||||
rgb = applyFog(rgb, vWorldPos);
|
||||
|
||||
fragColor = vec4(rgb, sampled.a);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ uniform mat4 uProjection;
|
|||
|
||||
out vec2 vTex;
|
||||
out vec3 vWorldNormal;
|
||||
out vec3 vWorldPos;
|
||||
|
||||
void main() {
|
||||
vTex = aTex;
|
||||
|
|
@ -17,5 +18,7 @@ void main() {
|
|||
// scale would require the inverse transpose; we accept that as a Phase 3+
|
||||
// concern.
|
||||
vWorldNormal = normalize(mat3(uModel) * aNormal);
|
||||
gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0);
|
||||
vec4 world = uModel * vec4(aPos, 1.0);
|
||||
vWorldPos = world.xyz;
|
||||
gl_Position = uProjection * uView * world;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
in vec2 vTex;
|
||||
in vec3 vWorldNormal;
|
||||
in float vLightingFactor;
|
||||
in vec3 vWorldPos;
|
||||
|
||||
out vec4 fragColor;
|
||||
|
||||
|
|
@ -18,14 +18,81 @@ uniform sampler2D uDiffuse;
|
|||
// 4 = InvAlpha — GL inverted-alpha blending; do NOT discard
|
||||
uniform int uTranslucencyKind;
|
||||
|
||||
// Phase G.1+G.2: shared scene-lighting UBO (see mesh.frag for layout docs).
|
||||
struct Light {
|
||||
vec4 posAndKind;
|
||||
vec4 dirAndRange;
|
||||
vec4 colorAndIntensity;
|
||||
vec4 coneAngleEtc;
|
||||
};
|
||||
layout(std140, binding = 1) uniform SceneLighting {
|
||||
Light uLights[8];
|
||||
vec4 uCellAmbient;
|
||||
vec4 uFogParams;
|
||||
vec4 uFogColor;
|
||||
vec4 uCameraAndTime;
|
||||
};
|
||||
|
||||
vec3 accumulateLights(vec3 N, vec3 worldPos) {
|
||||
vec3 lit = uCellAmbient.xyz;
|
||||
int active = int(uCellAmbient.w);
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
if (i >= active) break;
|
||||
|
||||
int kind = int(uLights[i].posAndKind.w);
|
||||
vec3 Lcol = uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w;
|
||||
|
||||
if (kind == 0) {
|
||||
vec3 Ldir = -uLights[i].dirAndRange.xyz;
|
||||
float ndl = max(0.0, dot(N, Ldir));
|
||||
lit += Lcol * ndl;
|
||||
} else {
|
||||
vec3 toL = uLights[i].posAndKind.xyz - worldPos;
|
||||
float d = length(toL);
|
||||
float range = uLights[i].dirAndRange.w;
|
||||
if (d < range && range > 1e-3) {
|
||||
vec3 Ldir = toL / max(d, 1e-4);
|
||||
float ndl = max(0.0, dot(N, Ldir));
|
||||
float atten = 1.0;
|
||||
if (kind == 2) {
|
||||
float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5);
|
||||
float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz);
|
||||
atten *= (cos_l > cos_edge) ? 1.0 : 0.0;
|
||||
}
|
||||
lit += Lcol * ndl * atten;
|
||||
}
|
||||
}
|
||||
}
|
||||
return lit;
|
||||
}
|
||||
|
||||
vec3 applyFog(vec3 lit, vec3 worldPos) {
|
||||
int mode = int(uFogParams.w);
|
||||
if (mode == 0) return lit;
|
||||
float d = length(worldPos - uCameraAndTime.xyz);
|
||||
float fogStart = uFogParams.x;
|
||||
float fogEnd = uFogParams.y;
|
||||
float span = max(1e-3, fogEnd - fogStart);
|
||||
float fog = clamp((d - fogStart) / span, 0.0, 1.0);
|
||||
return mix(lit, uFogColor.xyz, fog);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 color = texture(uDiffuse, vTex);
|
||||
|
||||
// Alpha cutout only for clip-map surfaces (doors, windows, vegetation).
|
||||
// Blended surface types must NOT discard here — that kills every
|
||||
// semi-transparent pixel before the blend stage runs.
|
||||
if (uTranslucencyKind == 1 && color.a < 0.5) discard;
|
||||
|
||||
// Apply pre-computed Lambert + ambient lighting factor from the vertex shader.
|
||||
fragColor = vec4(color.rgb * vLightingFactor, color.a);
|
||||
vec3 N = normalize(vWorldNormal);
|
||||
vec3 lit = accumulateLights(N, vWorldPos);
|
||||
|
||||
// Lightning flash — additive scene bump.
|
||||
lit += uFogParams.z * vec3(0.6, 0.6, 0.75);
|
||||
|
||||
// Retail clamp per-channel to 1.0 (r13 §13.1).
|
||||
lit = min(lit, vec3(1.0));
|
||||
|
||||
vec3 rgb = color.rgb * lit;
|
||||
rgb = applyFog(rgb, vWorldPos);
|
||||
fragColor = vec4(rgb, color.a);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,12 +16,10 @@ layout(location = 5) in vec4 aInstanceRow2;
|
|||
layout(location = 6) in vec4 aInstanceRow3;
|
||||
|
||||
uniform mat4 uViewProjection;
|
||||
uniform vec3 uLightDirection; // world-space light direction (points FROM sun, matching ACME)
|
||||
uniform float uAmbientIntensity;
|
||||
|
||||
out vec2 vTex;
|
||||
out vec3 vWorldNormal;
|
||||
out float vLightingFactor;
|
||||
out vec3 vWorldPos;
|
||||
|
||||
void main() {
|
||||
// Reconstruct the per-instance model matrix from its four row vectors.
|
||||
|
|
@ -30,11 +28,8 @@ void main() {
|
|||
vec4 worldPos = model * vec4(aPosition, 1.0);
|
||||
gl_Position = uViewProjection * worldPos;
|
||||
|
||||
vWorldPos = worldPos.xyz;
|
||||
// Transform normal into world space.
|
||||
vWorldNormal = normalize(mat3(model) * aNormal);
|
||||
vTex = aTexCoord;
|
||||
|
||||
// Lambert + ambient matching ACME StaticObject.vert:
|
||||
// LightingFactor = max(dot(Normal, -uLightDirection), 0.0) + uAmbientIntensity;
|
||||
vLightingFactor = max(dot(vWorldNormal, -uLightDirection), 0.0) + uAmbientIntensity;
|
||||
}
|
||||
|
|
|
|||
18
src/AcDream.App/Rendering/Shaders/particle.frag
Normal file
18
src/AcDream.App/Rendering/Shaders/particle.frag
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
#version 430 core
|
||||
|
||||
in vec2 vTex;
|
||||
in vec4 vColor;
|
||||
out vec4 fragColor;
|
||||
|
||||
// Procedural rain/snow streak — no texture, just a radial falloff
|
||||
// centred on the quad so droplets read as small soft circles. Good
|
||||
// enough for weather + basic spell auras without a texture pipeline.
|
||||
|
||||
void main() {
|
||||
// Signed distance from quad center (in UV space).
|
||||
vec2 d = vTex - vec2(0.5, 0.5);
|
||||
float r = length(d) * 2.0; // 0 at center, 1 at corner
|
||||
float falloff = smoothstep(1.0, 0.4, r);
|
||||
if (falloff < 0.02) discard;
|
||||
fragColor = vec4(vColor.rgb, vColor.a * falloff);
|
||||
}
|
||||
31
src/AcDream.App/Rendering/Shaders/particle.vert
Normal file
31
src/AcDream.App/Rendering/Shaders/particle.vert
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
#version 430 core
|
||||
|
||||
// Unit quad vertex (XY -0.5..+0.5)
|
||||
layout(location = 0) in vec2 aQuad;
|
||||
layout(location = 1) in vec2 aTex;
|
||||
|
||||
// Per-instance: world-space center + size
|
||||
layout(location = 2) in vec4 aPosAndSize;
|
||||
layout(location = 3) in vec4 aColor;
|
||||
|
||||
uniform mat4 uViewProjection;
|
||||
uniform vec3 uCameraRight;
|
||||
uniform vec3 uCameraUp;
|
||||
|
||||
out vec2 vTex;
|
||||
out vec4 vColor;
|
||||
|
||||
void main() {
|
||||
vec3 center = aPosAndSize.xyz;
|
||||
float size = aPosAndSize.w;
|
||||
|
||||
// Billboard: offset the quad vertex along the camera's right + up
|
||||
// basis vectors so it always faces the viewer.
|
||||
vec3 world = center
|
||||
+ uCameraRight * (aQuad.x * size)
|
||||
+ uCameraUp * (aQuad.y * size);
|
||||
|
||||
vTex = aTex;
|
||||
vColor = aColor;
|
||||
gl_Position = uViewProjection * vec4(world, 1.0);
|
||||
}
|
||||
51
src/AcDream.App/Rendering/Shaders/sky.frag
Normal file
51
src/AcDream.App/Rendering/Shaders/sky.frag
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
#version 430 core
|
||||
// Sky mesh fragment shader — sample the object's diffuse texture with
|
||||
// the scrolled UVs from the vertex stage. Unlit: sky meshes ARE the
|
||||
// gradient (r12 §2.2), not a surface lit by the sun.
|
||||
//
|
||||
// The per-keyframe replace override can dim the mesh (Transparent) or
|
||||
// brighten it (Luminosity); those two floats arrive as uTransparency /
|
||||
// uLuminosity uniforms.
|
||||
|
||||
in vec2 vTex;
|
||||
out vec4 fragColor;
|
||||
|
||||
uniform sampler2D uDiffuse;
|
||||
uniform float uTransparency; // 0 = fully visible, 1 = invisible
|
||||
uniform float uLuminosity; // 1 = normal, >1 makes the mesh glow
|
||||
uniform vec4 uTint; // per-object color tint (default white)
|
||||
|
||||
// Shared SceneLighting UBO — we only need the fog parameters to let the
|
||||
// horizon band of the sky blend smoothly into the scene's fog color at
|
||||
// the far edge, and the lightning flash to give storms their signature
|
||||
// strobe.
|
||||
struct Light {
|
||||
vec4 posAndKind;
|
||||
vec4 dirAndRange;
|
||||
vec4 colorAndIntensity;
|
||||
vec4 coneAngleEtc;
|
||||
};
|
||||
layout(std140, binding = 1) uniform SceneLighting {
|
||||
Light uLights[8];
|
||||
vec4 uCellAmbient;
|
||||
vec4 uFogParams;
|
||||
vec4 uFogColor;
|
||||
vec4 uCameraAndTime;
|
||||
};
|
||||
|
||||
void main() {
|
||||
vec4 sampled = texture(uDiffuse, vTex);
|
||||
|
||||
// Apply tint + luminosity. Retail's SkyObjReplace.Luminosity can push
|
||||
// above 1 to make the sun mesh brighter than its texture; r12 §2.3.
|
||||
vec3 rgb = sampled.rgb * uTint.rgb * uLuminosity;
|
||||
|
||||
// Lightning additive bump — makes the sky itself flash during storms.
|
||||
rgb += uFogParams.z * vec3(0.5, 0.5, 0.55);
|
||||
|
||||
rgb = min(rgb, vec3(1.2)); // soft clamp to let luminosity over-bright mildly
|
||||
|
||||
float a = sampled.a * (1.0 - uTransparency) * uTint.a;
|
||||
if (a < 0.01) discard;
|
||||
fragColor = vec4(rgb, a);
|
||||
}
|
||||
22
src/AcDream.App/Rendering/Shaders/sky.vert
Normal file
22
src/AcDream.App/Rendering/Shaders/sky.vert
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
#version 430 core
|
||||
// Sky mesh vertex shader — each celestial object is a GfxObj mesh
|
||||
// (sun billboard, cloud sheet, moon, star dome) rendered at large
|
||||
// distance with depth writes disabled. The view matrix has its
|
||||
// translation zeroed so the sky stays camera-centered; the projection
|
||||
// matrix has a huge far plane so 1e6-metre-away sky meshes never clip.
|
||||
|
||||
layout(location = 0) in vec3 aPos;
|
||||
layout(location = 1) in vec3 aNormal;
|
||||
layout(location = 2) in vec2 aTex;
|
||||
|
||||
uniform mat4 uModel; // per-object arc transform (r12 §2.1)
|
||||
uniform mat4 uSkyView; // camera view with M41..M43 = 0
|
||||
uniform mat4 uSkyProjection; // near=0.1, far=1e6
|
||||
uniform vec2 uUvScroll; // cumulative TexVelocityX/Y * time
|
||||
|
||||
out vec2 vTex;
|
||||
|
||||
void main() {
|
||||
vTex = aTex + uUvScroll;
|
||||
gl_Position = uSkyProjection * uSkyView * uModel * vec4(aPos, 1.0);
|
||||
}
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
#version 430 core
|
||||
// Per-cell terrain blending (Phase 3c.4) — ported from WorldBuilder's
|
||||
// Landscape.frag, trimmed of editor-specific features (grid, brush,
|
||||
// walkable-slope highlighting) and with Phase 3a/3b directional lighting
|
||||
// layered on at the end.
|
||||
// walkable-slope highlighting). Phase G extends this with the shared
|
||||
// SceneLighting UBO driving per-vertex sun bake + fragment-stage fog
|
||||
// + lightning flash.
|
||||
|
||||
in vec2 vBaseUV;
|
||||
in vec3 vWorldNormal;
|
||||
in float vLightingFactor;
|
||||
in vec3 vWorldPos;
|
||||
in vec3 vLightingRGB;
|
||||
in vec4 vOverlay0;
|
||||
in vec4 vOverlay1;
|
||||
in vec4 vOverlay2;
|
||||
|
|
@ -18,24 +20,34 @@ out vec4 fragColor;
|
|||
|
||||
uniform sampler2DArray uTerrain; // 33+ layers — TerrainAtlas.GlTexture
|
||||
uniform sampler2DArray uAlpha; // 8+ layers — TerrainAtlas.GlAlphaTexture
|
||||
uniform float xAmbient; // ambient intensity (matching ACME Landscape.frag)
|
||||
|
||||
// Shared scene-lighting UBO — fog + flash are consumed here; the per-vertex
|
||||
// AdjustPlanes bake already incorporated sun + ambient.
|
||||
struct Light {
|
||||
vec4 posAndKind;
|
||||
vec4 dirAndRange;
|
||||
vec4 colorAndIntensity;
|
||||
vec4 coneAngleEtc;
|
||||
};
|
||||
layout(std140, binding = 1) uniform SceneLighting {
|
||||
Light uLights[8];
|
||||
vec4 uCellAmbient;
|
||||
vec4 uFogParams;
|
||||
vec4 uFogColor;
|
||||
vec4 uCameraAndTime;
|
||||
};
|
||||
|
||||
// Per-texture tiling repeat count across a cell. WorldBuilder uses
|
||||
// uTexTiling[36] uploaded from the dats; we default to 1.0 (one tile per
|
||||
// cell, 8 tiles across a landblock) until we wire the array. The previous
|
||||
// Phase 2b/3 single-layer path tiled at ~2 per cell, so the world may read
|
||||
// slightly coarser at 1.0 — tunable here if it looks wrong.
|
||||
// cell, 8 tiles across a landblock).
|
||||
const float TILE = 1.0;
|
||||
|
||||
// Three-layer alpha-weighted composite. Each terrain overlay layer
|
||||
// contributes based on its own alpha mask; missing layers (h == 0) collapse
|
||||
// to transparent. Lifted verbatim from WorldBuilder's Landscape.frag.
|
||||
// Three-layer alpha-weighted composite.
|
||||
vec4 maskBlend3(vec4 t0, vec4 t1, vec4 t2, float h0, float h1, float h2) {
|
||||
float a0 = h0 == 0.0 ? 1.0 : t0.a;
|
||||
float a1 = h1 == 0.0 ? 1.0 : t1.a;
|
||||
float a2 = h2 == 0.0 ? 1.0 : t2.a;
|
||||
float aR = 1.0 - (a0 * a1 * a2);
|
||||
// avoid divide-by-zero when all three overlays are absent
|
||||
float aRsafe = max(aR, 1e-6);
|
||||
a0 = 1.0 - a0;
|
||||
a1 = 1.0 - a1;
|
||||
|
|
@ -82,7 +94,6 @@ vec4 combineRoad(vec2 baseUV, vec4 pRoad0, vec4 pRoad1) {
|
|||
result = texture(uTerrain, vec3(baseUV * TILE, pRoad0.z));
|
||||
if (pRoad0.w >= 0.0) {
|
||||
vec4 a0 = texture(uAlpha, vec3(pRoad0.xy, pRoad0.w));
|
||||
// Roads use inverted alpha (the mask stores NON-road coverage).
|
||||
result.a = 1.0 - a0.a;
|
||||
if (h1 > 0.0 && pRoad1.w >= 0.0) {
|
||||
vec4 a1 = texture(uAlpha, vec3(pRoad1.xy, pRoad1.w));
|
||||
|
|
@ -93,9 +104,18 @@ vec4 combineRoad(vec2 baseUV, vec4 pRoad0, vec4 pRoad1) {
|
|||
return result;
|
||||
}
|
||||
|
||||
vec3 applyFog(vec3 lit, vec3 worldPos) {
|
||||
int mode = int(uFogParams.w);
|
||||
if (mode == 0) return lit;
|
||||
float d = length(worldPos - uCameraAndTime.xyz);
|
||||
float fogStart = uFogParams.x;
|
||||
float fogEnd = uFogParams.y;
|
||||
float span = max(1e-3, fogEnd - fogStart);
|
||||
float fog = clamp((d - fogStart) / span, 0.0, 1.0);
|
||||
return mix(lit, uFogColor.xyz, fog);
|
||||
}
|
||||
|
||||
void main() {
|
||||
// Base color: if there's no base layer (sentinel -1) just render black
|
||||
// (shouldn't happen in valid data).
|
||||
vec4 baseColor = vec4(0.0);
|
||||
if (vBaseTexIdx >= 0.0) {
|
||||
baseColor = texture(uTerrain, vec3(vBaseUV * TILE, vBaseTexIdx));
|
||||
|
|
@ -115,9 +135,15 @@ void main() {
|
|||
vec3 roadMasked = roads.rgb * roads.a;
|
||||
vec3 rgb = clamp(baseMasked + ovlMasked + roadMasked, 0.0, 1.0);
|
||||
|
||||
// Lighting matching ACME Landscape.frag:
|
||||
// litColor = finalColor * (saturate(vLightingFactor) + xAmbient);
|
||||
vec3 litColor = rgb * (clamp(vLightingFactor, 0.0, 1.0) + xAmbient);
|
||||
// Apply the per-vertex baked sun+ambient.
|
||||
vec3 lit = rgb * min(vLightingRGB, vec3(1.0));
|
||||
|
||||
fragColor = vec4(litColor, 1.0);
|
||||
// Lightning flash — additive.
|
||||
float flash = uFogParams.z;
|
||||
lit += flash * vec3(0.6, 0.6, 0.75);
|
||||
|
||||
// Atmospheric fog.
|
||||
lit = applyFog(lit, vWorldPos);
|
||||
|
||||
fragColor = vec4(lit, 1.0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,28 @@ layout(location = 5) in uvec4 aPacked3; // bits: rot fields + splitDir (see bel
|
|||
|
||||
uniform mat4 uView;
|
||||
uniform mat4 uProjection;
|
||||
uniform vec3 xLightDirection; // world-space sun direction (matching ACME Landscape.vert)
|
||||
|
||||
// Phase G.1+G.2: sky/scene UBO. Terrain reads uLights[0] for the sun
|
||||
// (slot 0 is reserved) plus uCellAmbient for outdoor ambient; the fog
|
||||
// fields are consumed by the fragment stage.
|
||||
struct Light {
|
||||
vec4 posAndKind;
|
||||
vec4 dirAndRange;
|
||||
vec4 colorAndIntensity;
|
||||
vec4 coneAngleEtc;
|
||||
};
|
||||
layout(std140, binding = 1) uniform SceneLighting {
|
||||
Light uLights[8];
|
||||
vec4 uCellAmbient;
|
||||
vec4 uFogParams;
|
||||
vec4 uFogColor;
|
||||
vec4 uCameraAndTime;
|
||||
};
|
||||
|
||||
out vec2 vBaseUV;
|
||||
out vec3 vWorldNormal;
|
||||
out float vLightingFactor;
|
||||
out vec3 vWorldPos;
|
||||
out vec3 vLightingRGB; // pre-computed sun+ambient contribution for retail-style AdjustPlanes bake
|
||||
// Per-layer "UV.xy in cell-local 0..1 space, tex index .z, alpha index .w".
|
||||
// Negative .z means "layer not present, skip it in the fragment shader."
|
||||
out vec4 vOverlay0;
|
||||
|
|
@ -22,6 +39,11 @@ out vec4 vRoad0;
|
|||
out vec4 vRoad1;
|
||||
flat out float vBaseTexIdx;
|
||||
|
||||
// Retail's "ambient floor" constant from the decompiled AdjustPlanes
|
||||
// path (r13 §7, DAT_00796344). Even a back-lit vertex sees at least
|
||||
// this fraction of the sun color — NOT additive with ambient.
|
||||
const float MIN_FACTOR = 0.08;
|
||||
|
||||
// Port of WorldBuilder's Landscape.vert unpackOverlayLayer: sentinel-check
|
||||
// 255 → -1 (shader skips), then rotate the cell-local UV by the overlay's
|
||||
// 90° rotation count.
|
||||
|
|
@ -56,13 +78,6 @@ void main() {
|
|||
// gl_VertexID % 6. The CPU-side LandblockMesh emits vertices in a
|
||||
// specific order for each split direction; the table below must stay
|
||||
// in lockstep with LandblockMesh.Build's SWtoNE/SEtoNW branches.
|
||||
//
|
||||
// Corner labels: 0=BL (low x/y), 1=BR (high x, low y),
|
||||
// 2=TR (high x/y), 3=TL (low x, high y).
|
||||
// WorldBuilder assigns cell-local UV per corner:
|
||||
// 0 → (0, 1) 1 → (1, 1) 2 → (1, 0) 3 → (0, 0)
|
||||
// (the v axis is flipped vs. geometric convention — harmless, just a
|
||||
// texture-space choice).
|
||||
int vIdx = gl_VertexID % 6;
|
||||
int corner = 0;
|
||||
if (splitDir == 0u) {
|
||||
|
|
@ -90,12 +105,20 @@ void main() {
|
|||
else baseUV = vec2(0.0, 0.0);
|
||||
|
||||
vBaseUV = baseUV;
|
||||
// Vertices are baked in world space; normals need no model transform.
|
||||
vWorldPos = aPos;
|
||||
vWorldNormal = normalize(aNormal);
|
||||
|
||||
// Lambert diffuse term matching ACME Landscape.vert:
|
||||
// vLightingFactor = max(0.0, dot(vNormal, -normalize(xLightDirection)));
|
||||
vLightingFactor = max(0.0, dot(vWorldNormal, -normalize(xLightDirection)));
|
||||
// Retail AdjustPlanes bake (r13 §7):
|
||||
// L = max(N · -sunDir, MIN_FACTOR)
|
||||
// vertex.color = sun_color * L + ambient_color
|
||||
//
|
||||
// Slot 0 of the UBO is the sun (directional). We read its forward
|
||||
// vector and pre-multiplied color, apply the ambient floor, layer
|
||||
// in the scene ambient separately.
|
||||
vec3 sunDir = uLights[0].dirAndRange.xyz;
|
||||
vec3 sunCol = uLights[0].colorAndIntensity.xyz * uLights[0].colorAndIntensity.w;
|
||||
float L = max(dot(vWorldNormal, -sunDir), MIN_FACTOR);
|
||||
vLightingRGB = sunCol * L + uCellAmbient.xyz;
|
||||
|
||||
float baseTex = float(aPacked0.x);
|
||||
if (baseTex >= 254.0) baseTex = -1.0;
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -218,12 +218,11 @@ public sealed unsafe class TerrainChunkRenderer : IDisposable
|
|||
_shader.SetMatrix4("uView", camera.View);
|
||||
_shader.SetMatrix4("uProjection", camera.Projection);
|
||||
|
||||
// Lighting uniforms matching ACME Landscape.vert/frag.
|
||||
// LightDirection (0.5, 0.3, -0.3) from ACME GameScene.cs:238.
|
||||
// AmbientLightIntensity 0.45 from ACME LandscapeEditorSettings.cs:108.
|
||||
var lightDir = Vector3.Normalize(new Vector3(0.5f, 0.3f, -0.3f));
|
||||
_shader.SetVec3("xLightDirection", lightDir);
|
||||
_shader.SetFloat("xAmbient", 0.45f);
|
||||
// Phase G: light direction + ambient + fog come from the shared
|
||||
// SceneLighting UBO (binding=1) uploaded by GameWindow once per
|
||||
// frame. Terrain bakes per-vertex AdjustPlanes lighting (r13 §7)
|
||||
// from the UBO's slot-0 sun + uCellAmbient, then the fragment
|
||||
// stage adds fog + lightning flash. No per-program uniforms here.
|
||||
|
||||
// Terrain atlas on unit 0, alpha atlas on unit 1.
|
||||
_gl.ActiveTexture(TextureUnit.Texture0);
|
||||
|
|
|
|||
|
|
@ -65,6 +65,22 @@ public sealed class GpuWorldState
|
|||
|
||||
public bool IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId);
|
||||
|
||||
/// <summary>
|
||||
/// Try to grab the loaded record for a landblock — useful for callers
|
||||
/// that need to enumerate entities before the landblock is dropped
|
||||
/// (e.g. unregistering dynamic lights on a RemoveLandblock).
|
||||
/// </summary>
|
||||
public bool TryGetLandblock(uint landblockId, out LoadedLandblock? lb)
|
||||
{
|
||||
if (_loaded.TryGetValue(landblockId, out var found))
|
||||
{
|
||||
lb = found;
|
||||
return true;
|
||||
}
|
||||
lb = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store the axis-aligned bounding box for a loaded landblock. Called from
|
||||
/// the render thread after the terrain mesh is built and uploaded.
|
||||
|
|
|
|||
|
|
@ -109,6 +109,21 @@ public sealed class WorldSession : IDisposable
|
|||
/// </summary>
|
||||
public event Action<HearSpeech.Parsed>? SpeechHeard;
|
||||
|
||||
/// <summary>
|
||||
/// Phase G.1: latest server Portal Year tick count. Seeded from the
|
||||
/// ConnectRequest handshake (r12 §1.3 — server sends absolute game
|
||||
/// time as a double) and refreshed on every TimeSync-flagged packet.
|
||||
/// Subscribers feed this into <c>WorldTimeService.SyncFromServer</c>
|
||||
/// so client-local day/night stays in lockstep with the server clock.
|
||||
/// </summary>
|
||||
public event Action<double>? ServerTimeUpdated;
|
||||
|
||||
/// <summary>
|
||||
/// Latest server tick count from <see cref="ServerTimeUpdated"/>
|
||||
/// events. 0 until the handshake completes.
|
||||
/// </summary>
|
||||
public double LastServerTimeTicks { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Allow re-sending LoginComplete after a portal teleport. The normal
|
||||
/// _loginCompleteSent latch prevents duplicate sends on the initial spawn
|
||||
|
|
@ -224,6 +239,14 @@ public sealed class WorldSession : IDisposable
|
|||
|
||||
// Step 3: seed ISAAC, send ConnectResponse to port+1, with 200ms race delay
|
||||
var opt = cr.Optional;
|
||||
|
||||
// Phase G.1: server's initial PortalYearTicks (r12 §1.3) lives
|
||||
// in the ConnectRequest optional section. Publish it to
|
||||
// subscribers so WorldTimeService.SyncFromServer can seed the
|
||||
// client clock.
|
||||
LastServerTimeTicks = opt.ConnectRequestServerTime;
|
||||
ServerTimeUpdated?.Invoke(opt.ConnectRequestServerTime);
|
||||
|
||||
byte[] serverSeedBytes = new byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(serverSeedBytes, opt.ConnectRequestServerSeed);
|
||||
byte[] clientSeedBytes = new byte[4];
|
||||
|
|
@ -393,6 +416,19 @@ public sealed class WorldSession : IDisposable
|
|||
SendAck(serverHeader.Sequence);
|
||||
}
|
||||
|
||||
// Phase G.1: propagate TimeSync-flagged server time to anyone who
|
||||
// needs it (sky/day-night lerp in particular). Server sends this
|
||||
// periodically — no explicit opcode, just the header flag.
|
||||
if ((serverHeader.Flags & PacketHeaderFlags.TimeSync) != 0)
|
||||
{
|
||||
double t = dec.Packet!.Optional.TimeSync;
|
||||
if (t > 0)
|
||||
{
|
||||
LastServerTimeTicks = t;
|
||||
ServerTimeUpdated?.Invoke(t);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var frag in dec.Packet!.Fragments)
|
||||
{
|
||||
var body = _assembler.Ingest(frag, out _);
|
||||
|
|
|
|||
92
src/AcDream.Core/Lighting/LightInfoLoader.cs
Normal file
92
src/AcDream.Core/Lighting/LightInfoLoader.cs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Lighting;
|
||||
|
||||
/// <summary>
|
||||
/// Converts a <see cref="Setup"/>'s <c>Lights</c> dictionary (dat-level
|
||||
/// <see cref="LightInfo"/> records) into runtime <see cref="LightSource"/>
|
||||
/// instances the <see cref="LightManager"/> can consume.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail <see cref="LightInfo"/> fields (r13 §1):
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>ViewSpaceLocation</c>: local Frame relative to the owning part.</description></item>
|
||||
/// <item><description><c>Color</c>: packed ARGB. Alpha is ignored; channels go through <c>/255</c>.</description></item>
|
||||
/// <item><description><c>Intensity</c>: multiplies color for final diffuse.</description></item>
|
||||
/// <item><description><c>Falloff</c>: world metres — acts as the <see cref="LightSource.Range"/> hard cutoff.</description></item>
|
||||
/// <item><description><c>ConeAngle</c>: radians; 0 = point, >0 = spot cone.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class LightInfoLoader
|
||||
{
|
||||
/// <summary>
|
||||
/// Extract all lights from a Setup, positioned in the entity's
|
||||
/// world frame (via <paramref name="entityPosition"/> +
|
||||
/// <paramref name="entityRotation"/>). The dat's per-light Frame is
|
||||
/// treated as a local offset relative to the entity root; acdream
|
||||
/// doesn't yet transform through the animated part chain (retail's
|
||||
/// hand-held torches), so held lights render at the entity root
|
||||
/// until the animation hook layer handles per-part placement.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<LightSource> Load(
|
||||
Setup setup,
|
||||
uint ownerId,
|
||||
Vector3 entityPosition,
|
||||
Quaternion entityRotation)
|
||||
{
|
||||
var results = new List<LightSource>();
|
||||
if (setup?.Lights is null || setup.Lights.Count == 0) return results;
|
||||
|
||||
foreach (var kvp in setup.Lights)
|
||||
{
|
||||
var info = kvp.Value;
|
||||
if (info is null) continue;
|
||||
|
||||
// Local Frame offset into world space.
|
||||
Vector3 localOffset = Vector3.Zero;
|
||||
Quaternion localRot = Quaternion.Identity;
|
||||
if (info.ViewSpaceLocation is not null)
|
||||
{
|
||||
localOffset = new Vector3(
|
||||
info.ViewSpaceLocation.Origin.X,
|
||||
info.ViewSpaceLocation.Origin.Y,
|
||||
info.ViewSpaceLocation.Origin.Z);
|
||||
localRot = new Quaternion(
|
||||
info.ViewSpaceLocation.Orientation.X,
|
||||
info.ViewSpaceLocation.Orientation.Y,
|
||||
info.ViewSpaceLocation.Orientation.Z,
|
||||
info.ViewSpaceLocation.Orientation.W);
|
||||
}
|
||||
|
||||
// Transform local offset into world space via the entity's
|
||||
// rotation + translation. No per-part chain yet — held
|
||||
// torches track the entity's root for now.
|
||||
Vector3 worldPos = entityPosition + Vector3.Transform(localOffset, entityRotation);
|
||||
Quaternion worldRot = entityRotation * localRot;
|
||||
Vector3 forward = Vector3.Transform(Vector3.UnitY, worldRot);
|
||||
|
||||
var light = new LightSource
|
||||
{
|
||||
Kind = info.ConeAngle > 0f ? LightKind.Spot : LightKind.Point,
|
||||
WorldPosition = worldPos,
|
||||
WorldForward = forward,
|
||||
ColorLinear = new Vector3(
|
||||
(info.Color?.Red ?? 255) / 255f,
|
||||
(info.Color?.Green ?? 255) / 255f,
|
||||
(info.Color?.Blue ?? 255) / 255f),
|
||||
Intensity = info.Intensity,
|
||||
Range = info.Falloff,
|
||||
ConeAngle = info.ConeAngle,
|
||||
OwnerId = ownerId,
|
||||
IsLit = true,
|
||||
};
|
||||
results.Add(light);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
78
src/AcDream.Core/Lighting/LightingHookSink.cs
Normal file
78
src/AcDream.Core/Lighting/LightingHookSink.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Lighting;
|
||||
|
||||
/// <summary>
|
||||
/// Routes <see cref="SetLightHook"/> animation hooks to the
|
||||
/// <see cref="LightManager"/> — when a torch lights / extinguishes via
|
||||
/// an animation frame, flip the corresponding
|
||||
/// <see cref="LightSource.IsLit"/> latch. Per r13 §2 the hook is AC's
|
||||
/// way of saying "this Setup's baked-in LightInfo is now active".
|
||||
///
|
||||
/// <para>
|
||||
/// Registration: at entity spawn time the caller walks the Setup's
|
||||
/// <c>Lights</c> dictionary and registers a <see cref="LightSource"/>
|
||||
/// per <c>LightInfo</c>, tagging it with the owning entity id. When a
|
||||
/// hook fires later, we look up every light tagged to that owner and
|
||||
/// flip them all together (retail's SetLightHook is a per-setup
|
||||
/// boolean, not per-light).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class LightingHookSink : IAnimationHookSink
|
||||
{
|
||||
private readonly LightManager _lights;
|
||||
|
||||
// Index owner → the set of LightSource instances they registered.
|
||||
// Maintained lazily — populated on first RegisterLight for that owner.
|
||||
private readonly Dictionary<uint, List<LightSource>> _byOwner = new();
|
||||
|
||||
public LightingHookSink(LightManager lights)
|
||||
{
|
||||
_lights = lights ?? throw new System.ArgumentNullException(nameof(lights));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a light with the manager + track it by owner so later
|
||||
/// SetLightHook / Unregister calls can reach it.
|
||||
/// </summary>
|
||||
public void RegisterOwnedLight(LightSource light)
|
||||
{
|
||||
System.ArgumentNullException.ThrowIfNull(light);
|
||||
_lights.Register(light);
|
||||
if (!_byOwner.TryGetValue(light.OwnerId, out var list))
|
||||
{
|
||||
list = new List<LightSource>();
|
||||
_byOwner[light.OwnerId] = list;
|
||||
}
|
||||
list.Add(light);
|
||||
}
|
||||
|
||||
/// <summary>Drop every light tagged to this owner (despawn / unload).</summary>
|
||||
public void UnregisterOwner(uint ownerId)
|
||||
{
|
||||
if (!_byOwner.TryGetValue(ownerId, out var list)) return;
|
||||
foreach (var l in list) _lights.Unregister(l);
|
||||
_byOwner.Remove(ownerId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the set of registered lights for an owner — exposed so
|
||||
/// callers can reposition them (torch on hand follows hand part).
|
||||
/// </summary>
|
||||
public IReadOnlyList<LightSource>? GetOwnedLights(uint ownerId)
|
||||
{
|
||||
return _byOwner.TryGetValue(ownerId, out var list) ? list : null;
|
||||
}
|
||||
|
||||
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
|
||||
{
|
||||
if (hook is not SetLightHook slh) return;
|
||||
if (!_byOwner.TryGetValue(entityId, out var list)) return;
|
||||
|
||||
foreach (var light in list)
|
||||
light.IsLit = slh.LightsOn;
|
||||
}
|
||||
}
|
||||
150
src/AcDream.Core/Lighting/SceneLightingUbo.cs
Normal file
150
src/AcDream.Core/Lighting/SceneLightingUbo.cs
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using System.Runtime.InteropServices;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.Core.Lighting;
|
||||
|
||||
/// <summary>
|
||||
/// GPU-facing scene-lighting UBO layout. Matches the GLSL block in
|
||||
/// <c>mesh.frag</c> / <c>mesh_instanced.vert</c> / <c>terrain.vert</c>
|
||||
/// bound at binding=1. std140-compliant — each <c>vec4</c> member
|
||||
/// lives on a 16-byte boundary, arrays of <c>vec4</c> pack contiguously,
|
||||
/// and no pad elements are required because the struct's fields are
|
||||
/// already 16-byte-aligned.
|
||||
///
|
||||
/// <para>
|
||||
/// Layout (r13 §12.3 extended with R12 §13.2 sun+fog):
|
||||
/// <code>
|
||||
/// struct Light {
|
||||
/// vec4 posAndKind; // xyz = world pos, w = kind (0=dir, 1=point, 2=spot)
|
||||
/// vec4 dirAndRange; // xyz = forward, w = range (metres, hard cutoff)
|
||||
/// vec4 colorAndIntensity; // xyz = RGB linear, w = intensity scalar
|
||||
/// vec4 coneAngleEtc; // x = cone (rad), y=unused, z=unused, w=unused
|
||||
/// };
|
||||
///
|
||||
/// layout(std140, binding = 1) uniform SceneLighting {
|
||||
/// Light uLights[8]; // 8 * 64 bytes = 512 bytes
|
||||
/// vec4 uCellAmbient; // xyz = ambient RGB, w = active light count
|
||||
/// vec4 uFogParams; // x = fogStart, y = fogEnd, z = lightningFlash, w = fogMode
|
||||
/// vec4 uFogColor; // xyz = fog RGB, w = unused
|
||||
/// vec4 uCameraAndTime; // xyz = camera world pos, w = day fraction (debug / sky shader)
|
||||
/// };
|
||||
/// </code>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct UboLight
|
||||
{
|
||||
public Vector4 PosAndKind;
|
||||
public Vector4 DirAndRange;
|
||||
public Vector4 ColorAndIntensity;
|
||||
public Vector4 ConeAngleEtc;
|
||||
|
||||
/// <summary>Pack a <see cref="LightSource"/> into UBO-ready bytes.</summary>
|
||||
public static UboLight FromSource(LightSource ls)
|
||||
{
|
||||
return new UboLight
|
||||
{
|
||||
PosAndKind = new Vector4(ls.WorldPosition, (float)(int)ls.Kind),
|
||||
DirAndRange = new Vector4(ls.WorldForward, ls.Range),
|
||||
ColorAndIntensity = new Vector4(ls.ColorLinear, ls.Intensity),
|
||||
ConeAngleEtc = new Vector4(ls.ConeAngle, 0f, 0f, 0f),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Packed "zero" light — stored in unused UBO slots so shaders
|
||||
/// don't read garbage. <c>dirAndRange.w = 0</c> disables the light
|
||||
/// even if the active-count sentinel is wrong.</summary>
|
||||
public static UboLight Empty => new()
|
||||
{
|
||||
PosAndKind = Vector4.Zero,
|
||||
DirAndRange = Vector4.Zero,
|
||||
ColorAndIntensity = Vector4.Zero,
|
||||
ConeAngleEtc = Vector4.Zero,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full CPU-side scene-lighting UBO buffer. One per frame; lives on the
|
||||
/// render thread. The GL-side wrapper (<c>SceneLightingUboBinding</c>
|
||||
/// in AcDream.App) uploads this to binding=1 once per frame.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct SceneLightingUbo
|
||||
{
|
||||
// 8 lights × 64 bytes = 512 bytes
|
||||
public UboLight Light0;
|
||||
public UboLight Light1;
|
||||
public UboLight Light2;
|
||||
public UboLight Light3;
|
||||
public UboLight Light4;
|
||||
public UboLight Light5;
|
||||
public UboLight Light6;
|
||||
public UboLight Light7;
|
||||
|
||||
public Vector4 CellAmbient; // xyz = ambient RGB, w = active count
|
||||
public Vector4 FogParams; // x = fogStart, y = fogEnd, z = flash, w = fogMode
|
||||
public Vector4 FogColor; // xyz = color, w = reserved
|
||||
public Vector4 CameraAndTime; // xyz = camera pos, w = day fraction
|
||||
|
||||
public const int SizeInBytes = 8 * 64 + 4 * 16; // 576
|
||||
public const int BindingPoint = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Build the full per-frame UBO payload from:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>An already-ticked <see cref="LightManager"/>.</description></item>
|
||||
/// <item><description>The current <see cref="AtmosphereSnapshot"/> (sky + weather).</description></item>
|
||||
/// <item><description>The current camera world position (sky shader needs it, fog shader needs it).</description></item>
|
||||
/// <item><description>The current day fraction (sky shader needs it for scrolling clouds).</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static SceneLightingUbo Build(
|
||||
LightManager lights,
|
||||
in AtmosphereSnapshot atmo,
|
||||
Vector3 cameraWorldPos,
|
||||
float dayFraction)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(lights);
|
||||
|
||||
var ubo = new SceneLightingUbo();
|
||||
|
||||
// Pack up to 8 lights. Empty slots stay zero.
|
||||
var active = lights.Active;
|
||||
int count = active.Length;
|
||||
if (count > 8) count = 8;
|
||||
for (int i = 0; i < 8; i++)
|
||||
{
|
||||
var packed = (i < count && active[i] is not null)
|
||||
? UboLight.FromSource(active[i]!)
|
||||
: UboLight.Empty;
|
||||
SetLightAt(ref ubo, i, packed);
|
||||
}
|
||||
|
||||
ubo.CellAmbient = new Vector4(lights.CurrentAmbient.AmbientColor, count);
|
||||
ubo.FogParams = new Vector4(
|
||||
atmo.FogStart,
|
||||
atmo.FogEnd,
|
||||
atmo.LightningFlash,
|
||||
(float)(int)atmo.FogMode);
|
||||
ubo.FogColor = new Vector4(atmo.FogColor, 0f);
|
||||
ubo.CameraAndTime = new Vector4(cameraWorldPos, dayFraction);
|
||||
return ubo;
|
||||
}
|
||||
|
||||
private static void SetLightAt(ref SceneLightingUbo ubo, int i, in UboLight v)
|
||||
{
|
||||
switch (i)
|
||||
{
|
||||
case 0: ubo.Light0 = v; break;
|
||||
case 1: ubo.Light1 = v; break;
|
||||
case 2: ubo.Light2 = v; break;
|
||||
case 3: ubo.Light3 = v; break;
|
||||
case 4: ubo.Light4 = v; break;
|
||||
case 5: ubo.Light5 = v; break;
|
||||
case 6: ubo.Light6 = v; break;
|
||||
case 7: ubo.Light7 = v; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
297
src/AcDream.Core/World/SkyDescLoader.cs
Normal file
297
src/AcDream.Core/World/SkyDescLoader.cs
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.World;
|
||||
|
||||
/// <summary>
|
||||
/// One sky object (celestial mesh) per r12 §2. Each object has:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>A visibility window in day-fraction space.</description></item>
|
||||
/// <item><description>A <c>BeginAngle</c>/<c>EndAngle</c> sweep — the arc it traces across the sky during its window.</description></item>
|
||||
/// <item><description>A texture-velocity pair for UV scrolling (cloud drift, star twinkle).</description></item>
|
||||
/// <item><description>A GfxObj mesh (the actual geometry rendered at large distance).</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// This is the in-memory mirror of <c>DatReaderWriter.Types.SkyObject</c>
|
||||
/// scrubbed of dat-reader dependencies and with a couple of derived
|
||||
/// fields pre-computed. The per-keyframe <see cref="SkyObjectReplaceData"/>
|
||||
/// (r12 §2.3) lives off the owning <see cref="DatSkyKeyframeData"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SkyObjectData
|
||||
{
|
||||
public float BeginTime;
|
||||
public float EndTime;
|
||||
public float BeginAngle;
|
||||
public float EndAngle;
|
||||
public float TexVelocityX;
|
||||
public float TexVelocityY;
|
||||
public uint GfxObjId;
|
||||
public uint Properties;
|
||||
|
||||
/// <summary>Object is visible at day-fraction <paramref name="t"/>
|
||||
/// by retail's begin/end semantics (r12 §2). Three cases:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>Begin == End</c> → always visible.</description></item>
|
||||
/// <item><description><c>Begin < End</c> → daytime arc, visible in [Begin, End].</description></item>
|
||||
/// <item><description><c>Begin > End</c> → wraps midnight, visible in [Begin, 1) ∪ [0, End].</description></item>
|
||||
/// </list></summary>
|
||||
public bool IsVisible(float t)
|
||||
{
|
||||
if (BeginTime == EndTime) return true;
|
||||
if (BeginTime < EndTime) return t >= BeginTime && t <= EndTime;
|
||||
// Wrap around midnight.
|
||||
return t >= BeginTime || t <= EndTime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Arc progress 0..1 through the visibility window; gives the angle
|
||||
/// interpolation for <c>BeginAngle</c>→<c>EndAngle</c> (r12 §2).
|
||||
/// </summary>
|
||||
public float AngleProgress(float t)
|
||||
{
|
||||
if (BeginTime == EndTime) return 0f;
|
||||
float duration;
|
||||
float progress;
|
||||
if (BeginTime < EndTime)
|
||||
{
|
||||
duration = EndTime - BeginTime;
|
||||
progress = (t - BeginTime) / duration;
|
||||
}
|
||||
else
|
||||
{
|
||||
duration = (1f - BeginTime) + EndTime;
|
||||
progress = (t >= BeginTime)
|
||||
? (t - BeginTime) / duration
|
||||
: (t + (1f - BeginTime)) / duration;
|
||||
}
|
||||
return Math.Clamp(progress, 0f, 1f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Current arc angle in degrees given the day fraction. Linear
|
||||
/// interpolation between <see cref="BeginAngle"/> and <see cref="EndAngle"/>.
|
||||
/// </summary>
|
||||
public float CurrentAngle(float t)
|
||||
{
|
||||
if (BeginTime == EndTime) return BeginAngle;
|
||||
return BeginAngle + (EndAngle - BeginAngle) * AngleProgress(t);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-keyframe override for one sky object — swap its mesh at dusk,
|
||||
/// dim it, or rotate it (r12 §2.3). Indexed by
|
||||
/// <see cref="ObjectIndex"/> into the owning day group's SkyObjects list.
|
||||
/// </summary>
|
||||
public sealed class SkyObjectReplaceData
|
||||
{
|
||||
public uint ObjectIndex;
|
||||
public uint GfxObjId;
|
||||
public float Rotate;
|
||||
public float Transparent;
|
||||
public float Luminosity;
|
||||
public float MaxBright;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full lighting + sky-object-override data for one <c>SkyTimeOfDay</c>
|
||||
/// keyframe. Built alongside the <see cref="SkyKeyframe"/> the shaders
|
||||
/// consume — this form keeps the per-object overrides which the
|
||||
/// <c>SkyRenderer</c> needs to swap clouds for overcast keyframes.
|
||||
/// </summary>
|
||||
public sealed class DatSkyKeyframeData
|
||||
{
|
||||
public SkyKeyframe Keyframe;
|
||||
public IReadOnlyList<SkyObjectReplaceData> Replaces = Array.Empty<SkyObjectReplaceData>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One <c>DayGroup</c> from retail's Region dat — a self-contained
|
||||
/// weather regime. Retail Dereth ships ~3 day groups (clear, overcast,
|
||||
/// storm) and the client rolls one per day. r12 §11 describes this.
|
||||
/// </summary>
|
||||
public sealed class DayGroupData
|
||||
{
|
||||
public float ChanceOfOccur;
|
||||
public string Name = "";
|
||||
public IReadOnlyList<SkyObjectData> SkyObjects = Array.Empty<SkyObjectData>();
|
||||
public IReadOnlyList<DatSkyKeyframeData> SkyTimes = Array.Empty<DatSkyKeyframeData>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fully-loaded skybox data pulled from the Region dat (0x13000000).
|
||||
/// Has everything the renderer + weather system need to produce a
|
||||
/// retail-faithful day/night cycle:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>A <see cref="SkyStateProvider"/> ready to drop into <see cref="WorldTimeService"/>.</description></item>
|
||||
/// <item><description>A list of day groups for weather picking.</description></item>
|
||||
/// <item><description>Calendar constants (<c>DayLength</c>, etc) for cross-checking.</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class LoadedSkyDesc
|
||||
{
|
||||
public double TickSize;
|
||||
public double LightTickSize;
|
||||
public IReadOnlyList<DayGroupData> DayGroups = Array.Empty<DayGroupData>();
|
||||
|
||||
/// <summary>
|
||||
/// Default day group — currently group 0 per WorldBuilder's
|
||||
/// <c>SkyboxRenderManager.Render</c>. Weather integration later picks
|
||||
/// the current day's group by <c>ChanceOfOccur</c>.
|
||||
/// </summary>
|
||||
public DayGroupData? DefaultDayGroup =>
|
||||
DayGroups.Count > 0 ? DayGroups[0] : null;
|
||||
|
||||
/// <summary>
|
||||
/// Build a shader-facing <see cref="SkyStateProvider"/> for the default day group.
|
||||
/// </summary>
|
||||
public SkyStateProvider BuildDefaultProvider()
|
||||
{
|
||||
var grp = DefaultDayGroup;
|
||||
if (grp is null || grp.SkyTimes.Count == 0) return SkyStateProvider.Default();
|
||||
return new SkyStateProvider(grp.SkyTimes.Select(s => s.Keyframe).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the Region dat (0x13000000) into strongly-typed acdream data.
|
||||
/// Safe to call off the render thread as long as the underlying
|
||||
/// <see cref="DatCollection"/> isn't being mutated (acdream's one-shot
|
||||
/// startup path already holds the dat lock during Region reads).
|
||||
///
|
||||
/// <para>
|
||||
/// Retail stores the entire world's sky + calendar in this single record
|
||||
/// — there's only ever one <c>Region</c>. The loader reads the SkyDesc
|
||||
/// out of <c>region.SkyInfo</c>, iterates every DayGroup, and converts
|
||||
/// each <c>SkyTimeOfDay</c> to our <see cref="SkyKeyframe"/> record.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The SunColor / AmbientColor fields store the color × brightness
|
||||
/// product so the shader UBO layout can stay a flat <c>vec3</c> without
|
||||
/// extra multiplies per pixel. See r12 §4.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class SkyDescLoader
|
||||
{
|
||||
public const uint RegionDatId = 0x13000000u;
|
||||
|
||||
/// <summary>
|
||||
/// Load + parse. Returns <c>null</c> if the Region doesn't have
|
||||
/// <see cref="PartsMask.HasSkyInfo"/> or the dat is absent.
|
||||
/// </summary>
|
||||
public static LoadedSkyDesc? LoadFromDat(DatCollection dats)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dats);
|
||||
var region = dats.Get<Region>(RegionDatId);
|
||||
if (region is null) return null;
|
||||
return LoadFromRegion(region);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert an in-memory Region object to our domain data.
|
||||
/// Separated so tests can feed hand-built Regions without the dat
|
||||
/// pipeline.
|
||||
/// </summary>
|
||||
public static LoadedSkyDesc? LoadFromRegion(Region region)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(region);
|
||||
if (!region.PartsMask.HasFlag(PartsMask.HasSkyInfo) || region.SkyInfo is null)
|
||||
return null;
|
||||
|
||||
var sky = region.SkyInfo;
|
||||
var dayGroups = new List<DayGroupData>(sky.DayGroups.Count);
|
||||
|
||||
foreach (var dg in sky.DayGroups)
|
||||
{
|
||||
var objs = dg.SkyObjects.Select(ConvertSkyObject).ToList();
|
||||
var times = dg.SkyTime.Select(ConvertTimeOfDay).ToList();
|
||||
|
||||
dayGroups.Add(new DayGroupData
|
||||
{
|
||||
ChanceOfOccur = dg.ChanceOfOccur,
|
||||
Name = dg.DayName?.ToString() ?? "",
|
||||
SkyObjects = objs,
|
||||
SkyTimes = times,
|
||||
});
|
||||
}
|
||||
|
||||
return new LoadedSkyDesc
|
||||
{
|
||||
TickSize = sky.TickSize,
|
||||
LightTickSize = sky.LightTickSize,
|
||||
DayGroups = dayGroups,
|
||||
};
|
||||
}
|
||||
|
||||
private static SkyObjectData ConvertSkyObject(SkyObject s) => new()
|
||||
{
|
||||
BeginTime = s.BeginTime,
|
||||
EndTime = s.EndTime,
|
||||
BeginAngle = s.BeginAngle,
|
||||
EndAngle = s.EndAngle,
|
||||
TexVelocityX = s.TexVelocityX,
|
||||
TexVelocityY = s.TexVelocityY,
|
||||
GfxObjId = s.DefaultGfxObjectId?.DataId ?? 0u,
|
||||
Properties = s.Properties,
|
||||
};
|
||||
|
||||
private static DatSkyKeyframeData ConvertTimeOfDay(SkyTimeOfDay s)
|
||||
{
|
||||
var replaces = s.SkyObjReplace.Select(r => new SkyObjectReplaceData
|
||||
{
|
||||
ObjectIndex = r.ObjectIndex,
|
||||
GfxObjId = r.GfxObjId?.DataId ?? 0u,
|
||||
Rotate = r.Rotate,
|
||||
Transparent = r.Transparent,
|
||||
Luminosity = r.Luminosity,
|
||||
MaxBright = r.MaxBright,
|
||||
}).ToList();
|
||||
|
||||
var fogMode = s.WorldFog switch
|
||||
{
|
||||
1u => FogMode.Linear,
|
||||
2u => FogMode.Exp,
|
||||
3u => FogMode.Exp2,
|
||||
_ => FogMode.Off,
|
||||
};
|
||||
|
||||
var kf = new SkyKeyframe(
|
||||
Begin: s.Begin,
|
||||
SunHeadingDeg: s.DirHeading,
|
||||
SunPitchDeg: s.DirPitch,
|
||||
SunColor: ColorToVec3(s.DirColor) * s.DirBright,
|
||||
AmbientColor: ColorToVec3(s.AmbColor) * s.AmbBright,
|
||||
FogColor: ColorToVec3(s.WorldFogColor),
|
||||
FogDensity: 0f,
|
||||
FogStart: s.MinWorldFog,
|
||||
FogEnd: s.MaxWorldFog,
|
||||
FogMode: fogMode);
|
||||
|
||||
return new DatSkyKeyframeData
|
||||
{
|
||||
Keyframe = kf,
|
||||
Replaces = replaces,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ColorARGB"/> stores bytes as B,G,R,A — but the logical
|
||||
/// channel mapping is just "R/G/B in 0..255". Convert to linear
|
||||
/// 0..1 <see cref="Vector3"/>. Alpha is ignored (retail lighting
|
||||
/// doesn't use it).
|
||||
/// </summary>
|
||||
public static Vector3 ColorToVec3(ColorARGB? c)
|
||||
{
|
||||
if (c is null) return Vector3.One;
|
||||
return new Vector3(c.Red / 255f, c.Green / 255f, c.Blue / 255f);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,25 +5,53 @@ using System.Numerics;
|
|||
namespace AcDream.Core.World;
|
||||
|
||||
/// <summary>
|
||||
/// One sky keyframe — the lighting + fog state for a specific day-fraction.
|
||||
/// Multiple keyframes across [0, 1) interpolate linearly (with angular
|
||||
/// wrap on sun direction) to produce the current sky state.
|
||||
/// Fog modes mirroring retail's <c>D3DFOGMODE</c>. Retail only ever uses
|
||||
/// <see cref="Off"/> and <see cref="Linear"/>; the Exp variants are
|
||||
/// supported by the dat schema but never appear in shipped data. See r12
|
||||
/// §5 and <c>SkyTimeOfDay.WorldFog</c> (dat <c>uint</c>).
|
||||
/// </summary>
|
||||
public enum FogMode
|
||||
{
|
||||
Off = 0,
|
||||
Linear = 1,
|
||||
Exp = 2,
|
||||
Exp2 = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One sky keyframe — the full lighting + fog state for a specific
|
||||
/// day-fraction. Multiple keyframes across <c>[0, 1)</c> interpolate
|
||||
/// linearly (with angular-shortest-arc wrap on sun direction) to produce
|
||||
/// the current sky state.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail's <c>SkyTimeOfDay</c> dat struct carries this exact data plus
|
||||
/// references to sky objects (sun mesh, moon mesh, cloud layer) which
|
||||
/// belong to the renderer. This class exposes the lighting-relevant
|
||||
/// subset — sun direction, sun color, ambient color, fog.
|
||||
/// belong to the renderer. This record exposes the shader-relevant
|
||||
/// subset — sun direction, sun color, ambient color, linear fog. See
|
||||
/// <c>references/DatReaderWriter/DatReaderWriter/Generated/Types/SkyTimeOfDay.generated.cs</c>
|
||||
/// and r12 §4 + §5.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Colors are in LINEAR RGB, already pre-multiplied by their brightness
|
||||
/// scalar so the shader can plug them straight into the UBO without
|
||||
/// knowing about <c>DirBright</c> / <c>AmbBright</c>. Range is loosely
|
||||
/// [0, N] — retail dusk tints have channels above 1.0 and the frag
|
||||
/// shader clamps after lighting math.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public readonly record struct SkyKeyframe(
|
||||
float Begin, // [0, 1] day-fraction this keyframe kicks in
|
||||
float SunHeadingDeg, // compass heading (0=N, 90=E, 180=S, 270=W)
|
||||
float SunPitchDeg, // elevation above horizon (-90=below, +90=zenith)
|
||||
Vector3 SunColor, // RGB linear, post-brightness multiply
|
||||
Vector3 AmbientColor,
|
||||
float Begin, // [0, 1] day-fraction this keyframe kicks in
|
||||
float SunHeadingDeg, // compass heading (0=N, 90=E, 180=S, 270=W)
|
||||
float SunPitchDeg, // elevation above horizon (-90=below, +90=zenith)
|
||||
Vector3 SunColor, // RGB linear, post-brightness multiply
|
||||
Vector3 AmbientColor, // RGB linear, post-brightness multiply
|
||||
Vector3 FogColor,
|
||||
float FogDensity);
|
||||
float FogDensity, // retained for tests; derive from FogStart/End
|
||||
float FogStart = 80f, // meters (retail default ~120 clear, ~40 storm)
|
||||
float FogEnd = 350f, // meters (retail default ~350 clear, ~150 storm)
|
||||
FogMode FogMode = FogMode.Linear);
|
||||
|
||||
/// <summary>
|
||||
/// Sky keyframe interpolator — given a day fraction in [0, 1), returns
|
||||
|
|
@ -42,9 +70,8 @@ public readonly record struct SkyKeyframe(
|
|||
/// with wrap handling.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// Lerp every vector component; SLERP the sun direction
|
||||
/// quaternions to avoid artifacts when heading wraps (e.g. k1.Heading
|
||||
/// = 350°, k2.Heading = 10°).
|
||||
/// Lerp every vector component; use shortest-arc lerp for the sun
|
||||
/// heading so k1=350° → k2=10° doesn't sweep backwards across the sky.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
|
|
@ -64,12 +91,20 @@ public sealed class SkyStateProvider
|
|||
}
|
||||
|
||||
public int KeyframeCount => _keyframes.Count;
|
||||
public IReadOnlyList<SkyKeyframe> Keyframes => _keyframes;
|
||||
|
||||
/// <summary>
|
||||
/// Default keyframe set based on retail observations — sunrise at 6am,
|
||||
/// noon at 12pm, sunset at 6pm. Used when the dat-loaded set isn't
|
||||
/// available yet or the player is in a region whose Region dat
|
||||
/// doesn't override it.
|
||||
///
|
||||
/// <para>
|
||||
/// Fog values approximate retail clear-weather defaults: ~80m..~350m
|
||||
/// linear fog with color matching the horizon band so mountains at
|
||||
/// distance fade into the sky instead of popping at the clip plane.
|
||||
/// See r12 §5.1.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static SkyStateProvider Default()
|
||||
{
|
||||
|
|
@ -83,7 +118,10 @@ public sealed class SkyStateProvider
|
|||
SunColor: new Vector3(0.02f, 0.02f, 0.08f), // deep blue
|
||||
AmbientColor: new Vector3(0.05f, 0.05f, 0.12f),
|
||||
FogColor: new Vector3(0.02f, 0.02f, 0.05f),
|
||||
FogDensity: 0.004f),
|
||||
FogDensity: 0.004f,
|
||||
FogStart: 30f,
|
||||
FogEnd: 180f,
|
||||
FogMode: FogMode.Linear),
|
||||
new SkyKeyframe(
|
||||
Begin: 0.25f,
|
||||
SunHeadingDeg: 90f, // east at dawn
|
||||
|
|
@ -91,7 +129,10 @@ public sealed class SkyStateProvider
|
|||
SunColor: new Vector3(1.0f, 0.7f, 0.4f), // sunrise warm
|
||||
AmbientColor: new Vector3(0.4f, 0.35f, 0.3f),
|
||||
FogColor: new Vector3(0.8f, 0.55f, 0.4f),
|
||||
FogDensity: 0.002f),
|
||||
FogDensity: 0.002f,
|
||||
FogStart: 60f,
|
||||
FogEnd: 260f,
|
||||
FogMode: FogMode.Linear),
|
||||
new SkyKeyframe(
|
||||
Begin: 0.5f,
|
||||
SunHeadingDeg: 180f, // south at noon
|
||||
|
|
@ -99,7 +140,10 @@ public sealed class SkyStateProvider
|
|||
SunColor: new Vector3(1.0f, 0.98f, 0.95f), // bright white-ish
|
||||
AmbientColor: new Vector3(0.5f, 0.5f, 0.55f),
|
||||
FogColor: new Vector3(0.7f, 0.75f, 0.85f),
|
||||
FogDensity: 0.0008f),
|
||||
FogDensity: 0.0008f,
|
||||
FogStart: 120f,
|
||||
FogEnd: 500f,
|
||||
FogMode: FogMode.Linear),
|
||||
new SkyKeyframe(
|
||||
Begin: 0.75f,
|
||||
SunHeadingDeg: 270f, // west at dusk
|
||||
|
|
@ -107,7 +151,10 @@ public sealed class SkyStateProvider
|
|||
SunColor: new Vector3(0.95f, 0.4f, 0.25f), // sunset red
|
||||
AmbientColor: new Vector3(0.35f, 0.25f, 0.25f),
|
||||
FogColor: new Vector3(0.85f, 0.45f, 0.35f),
|
||||
FogDensity: 0.002f),
|
||||
FogDensity: 0.002f,
|
||||
FogStart: 60f,
|
||||
FogEnd: 260f,
|
||||
FogMode: FogMode.Linear),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -145,21 +192,34 @@ public sealed class SkyStateProvider
|
|||
u = Math.Clamp(u, 0f, 1f);
|
||||
|
||||
// Angular lerp for sun heading: pick shortest arc.
|
||||
float h1 = k1.SunHeadingDeg;
|
||||
float h2 = k2.SunHeadingDeg;
|
||||
float delta = h2 - h1;
|
||||
while (delta > 180f) delta -= 360f;
|
||||
while (delta < -180f) delta += 360f;
|
||||
float heading = h1 + delta * u;
|
||||
float heading = ShortestAngleLerp(k1.SunHeadingDeg, k2.SunHeadingDeg, u);
|
||||
|
||||
// Fog mode doesn't interpolate — pick k1's mode (retail uses Linear everywhere).
|
||||
return new SkyKeyframe(
|
||||
Begin: t,
|
||||
Begin: t,
|
||||
SunHeadingDeg: heading,
|
||||
SunPitchDeg: k1.SunPitchDeg + (k2.SunPitchDeg - k1.SunPitchDeg) * u,
|
||||
SunPitchDeg: Lerp(k1.SunPitchDeg, k2.SunPitchDeg, u),
|
||||
SunColor: Vector3.Lerp(k1.SunColor, k2.SunColor, u),
|
||||
AmbientColor: Vector3.Lerp(k1.AmbientColor, k2.AmbientColor, u),
|
||||
FogColor: Vector3.Lerp(k1.FogColor, k2.FogColor, u),
|
||||
FogDensity: k1.FogDensity + (k2.FogDensity - k1.FogDensity) * u);
|
||||
FogDensity: Lerp(k1.FogDensity, k2.FogDensity, u),
|
||||
FogStart: Lerp(k1.FogStart, k2.FogStart, u),
|
||||
FogEnd: Lerp(k1.FogEnd, k2.FogEnd, u),
|
||||
FogMode: k1.FogMode);
|
||||
}
|
||||
|
||||
private static float Lerp(float a, float b, float u) => a + (b - a) * u;
|
||||
|
||||
/// <summary>
|
||||
/// Shortest-arc heading lerp: r12 §4. If <c>a=350</c> and <c>b=10</c>
|
||||
/// the lerp walks 20° forward through 0° rather than 340° backward.
|
||||
/// </summary>
|
||||
public static float ShortestAngleLerp(float aDeg, float bDeg, float u)
|
||||
{
|
||||
float delta = bDeg - aDeg;
|
||||
while (delta > 180f) delta -= 360f;
|
||||
while (delta < -180f) delta += 360f;
|
||||
return aDeg + delta * u;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -185,42 +245,89 @@ public sealed class SkyStateProvider
|
|||
/// Service that turns server-delivered tick counts into live sky state.
|
||||
/// Owns the "current time" clock (seeded from server sync, advanced by
|
||||
/// real-time elapsed between syncs).
|
||||
///
|
||||
/// <para>
|
||||
/// Supports a debug "time override" (slash-command <c>/time 0.5</c>) that
|
||||
/// forces a specific day fraction regardless of server sync — used for
|
||||
/// screenshots and visual debugging. The override is transient and gets
|
||||
/// cleared on the next TimeSync packet.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class WorldTimeService
|
||||
{
|
||||
private readonly SkyStateProvider _sky;
|
||||
private SkyStateProvider _sky;
|
||||
private double _lastSyncedTicks;
|
||||
private DateTime _lastSyncedWallClockUtc = DateTime.UtcNow;
|
||||
|
||||
private float? _debugDayFractionOverride;
|
||||
|
||||
/// <summary>
|
||||
/// Rate at which in-game time advances relative to real time. Retail
|
||||
/// default is 1.0 (one wall-clock second = one in-game tick). Server
|
||||
/// config can override via <c>SkyDesc.TickSize</c>; see r12 §1.2.
|
||||
/// </summary>
|
||||
public double TickSize { get; set; } = 1.0;
|
||||
|
||||
public WorldTimeService(SkyStateProvider sky)
|
||||
{
|
||||
_sky = sky ?? throw new ArgumentNullException(nameof(sky));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hot-swap the keyframe source — typically called once at world-load
|
||||
/// time after the Region dat has been parsed by <see cref="SkyDescLoader"/>.
|
||||
/// </summary>
|
||||
public void SetProvider(SkyStateProvider sky)
|
||||
{
|
||||
_sky = sky ?? throw new ArgumentNullException(nameof(sky));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the authoritative tick count from a server TimeSync packet.
|
||||
/// Clears any debug override.
|
||||
/// </summary>
|
||||
public void SyncFromServer(double serverTicks)
|
||||
{
|
||||
_lastSyncedTicks = serverTicks;
|
||||
_lastSyncedWallClockUtc = DateTime.UtcNow;
|
||||
_debugDayFractionOverride = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Debug-only: force a specific day fraction in [0, 1). Overrides
|
||||
/// server-synced time until cleared by <see cref="SyncFromServer"/>
|
||||
/// or <see cref="ClearDebugTime"/>.
|
||||
/// </summary>
|
||||
public void SetDebugTime(float dayFraction)
|
||||
{
|
||||
_debugDayFractionOverride = dayFraction;
|
||||
}
|
||||
|
||||
public void ClearDebugTime() => _debugDayFractionOverride = null;
|
||||
|
||||
/// <summary>
|
||||
/// Current ticks at <see cref="DateTime.UtcNow"/>, advanced from the
|
||||
/// last sync by real-time elapsed seconds.
|
||||
/// last sync by real-time elapsed seconds times <see cref="TickSize"/>.
|
||||
/// </summary>
|
||||
public double NowTicks
|
||||
{
|
||||
get
|
||||
{
|
||||
double elapsed = (DateTime.UtcNow - _lastSyncedWallClockUtc).TotalSeconds;
|
||||
return _lastSyncedTicks + elapsed;
|
||||
return _lastSyncedTicks + elapsed * TickSize;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Current day fraction in [0, 1).</summary>
|
||||
public double DayFraction => DerethDateTime.DayFraction(NowTicks);
|
||||
public double DayFraction
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_debugDayFractionOverride.HasValue)
|
||||
return _debugDayFractionOverride.Value;
|
||||
return DerethDateTime.DayFraction(NowTicks);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Current sky lighting state.</summary>
|
||||
public SkyKeyframe CurrentSky => _sky.Interpolate((float)DayFraction);
|
||||
|
|
|
|||
309
src/AcDream.Core/World/WeatherState.cs
Normal file
309
src/AcDream.Core/World/WeatherState.cs
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.World;
|
||||
|
||||
/// <summary>
|
||||
/// Client-local atmospheric regime. Retail AC has no server weather
|
||||
/// opcode (r12 §6) — the client picks a state per in-game day via a
|
||||
/// deterministic seeded RNG so all players on the same server see the
|
||||
/// same weather without any packets. Transitions take ~10 seconds.
|
||||
///
|
||||
/// <para>
|
||||
/// The rendering side reads <see cref="Kind"/> to decide whether to
|
||||
/// spawn rain/snow particles and which cloud mesh override to select.
|
||||
/// The <see cref="Intensity"/> field lets the fog / particle rate /
|
||||
/// cloud-darkness terms ease in and out smoothly rather than popping.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public enum WeatherKind
|
||||
{
|
||||
Clear = 0,
|
||||
Overcast = 1,
|
||||
Rain = 2,
|
||||
Snow = 3,
|
||||
Storm = 4,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-forced fog override (retail <c>EnvironChangeType</c>). When
|
||||
/// the server sends <c>AdminEnvirons</c> (0xEA60) with one of the
|
||||
/// non-<see cref="None"/> values, the client overrides its locally-computed
|
||||
/// fog color and density with the tint shown below. See r12 §5.2 and
|
||||
/// <c>references/ACE/Source/ACE.Entity/Enum/EnvironChangeType.cs</c>.
|
||||
/// </summary>
|
||||
public enum EnvironOverride
|
||||
{
|
||||
None = 0x00, // clear override, revert to dat-driven fog
|
||||
RedFog = 0x01,
|
||||
BlueFog = 0x02,
|
||||
WhiteFog = 0x03,
|
||||
GreenFog = 0x04,
|
||||
BlackFog = 0x05,
|
||||
BlackFog2 = 0x06,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full per-frame atmosphere state consumed by the shader + particle
|
||||
/// systems. Built by <see cref="WeatherSystem"/> from
|
||||
/// <list type="bullet">
|
||||
/// <item><description>the interpolated <see cref="SkyKeyframe"/>,</description></item>
|
||||
/// <item><description>the current <see cref="WeatherKind"/>,</description></item>
|
||||
/// <item><description>a possibly-active <see cref="EnvironOverride"/>,</description></item>
|
||||
/// <item><description>a transient lightning-flash bump.</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public readonly record struct AtmosphereSnapshot(
|
||||
WeatherKind Kind,
|
||||
float Intensity, // 0..1, eases on state transitions
|
||||
Vector3 FogColor, // final fog color (may be overridden)
|
||||
float FogStart,
|
||||
float FogEnd,
|
||||
FogMode FogMode,
|
||||
float LightningFlash, // 0..1, decays from strike moment
|
||||
EnvironOverride Override);
|
||||
|
||||
/// <summary>
|
||||
/// Weather state machine — deterministic per-day RNG picks the weather
|
||||
/// kind; a 10-second ease blends fog + particle density between old
|
||||
/// and new states. Also owns the lightning-flash timer for storms.
|
||||
///
|
||||
/// <para>
|
||||
/// Algorithm (r12 §6.1–6.2):
|
||||
/// <list type="number">
|
||||
/// <item><description>
|
||||
/// Derive a deterministic <c>Random(dayIndex)</c> per in-game day.
|
||||
/// Roll a weighted pick from a table matching retail's rough
|
||||
/// 70/15/10/5 distribution (Clear dominates).
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// When the kind changes, store a <c>transitionStart</c> timestamp
|
||||
/// and tween <see cref="AtmosphereSnapshot.Intensity"/> from 0 → 1
|
||||
/// over <see cref="TransitionSeconds"/>.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// Storm kind only: every 8–30 seconds fire a lightning flash; the
|
||||
/// shader reads <see cref="AtmosphereSnapshot.LightningFlash"/> as
|
||||
/// an additive scene bump that decays with a 200 ms time constant.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// Any server <see cref="EnvironOverride"/> beats the local picks —
|
||||
/// stick the override fog color and density in the snapshot until
|
||||
/// the server sends <see cref="EnvironOverride.None"/>.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class WeatherSystem
|
||||
{
|
||||
public const float TransitionSeconds = 10f;
|
||||
|
||||
// Flash visual parameters (r12 §9). The spike rises to 1.0 in ~50ms
|
||||
// and decays exponentially with a time constant of ~200ms.
|
||||
private const float FlashDecay = 1f / 0.200f; // 1 / τ seconds
|
||||
private const float FlashPeakHoldS = 0.05f;
|
||||
|
||||
// Retail storm cadence: 8–30 seconds between strikes.
|
||||
private const float StrikeIntervalMinS = 8f;
|
||||
private const float StrikeIntervalMaxS = 30f;
|
||||
|
||||
// Overcast-kind fog feels like ~40–150m retail range (r12 §5.1).
|
||||
private const float OvercastFogStart = 40f;
|
||||
private const float OvercastFogEnd = 150f;
|
||||
private const float StormFogStart = 25f;
|
||||
private const float StormFogEnd = 90f;
|
||||
|
||||
private WeatherKind _kind = WeatherKind.Clear;
|
||||
private WeatherKind _previousKind = WeatherKind.Clear;
|
||||
private float _transitionT; // 0..1 through the cross-fade
|
||||
|
||||
private float _flashLevel;
|
||||
private float _flashAge; // seconds since last strike
|
||||
private float _nextStrikeInS;
|
||||
|
||||
private EnvironOverride _override;
|
||||
|
||||
private int _rolledDayIndex = int.MinValue; // unrolled == "pick one"
|
||||
|
||||
private readonly Random _strikeJitter;
|
||||
|
||||
public WeatherSystem(Random? rng = null)
|
||||
{
|
||||
_strikeJitter = rng ?? new Random(unchecked((int)0xDCAE_5001u));
|
||||
_nextStrikeInS = 12f;
|
||||
}
|
||||
|
||||
/// <summary>Current active weather.</summary>
|
||||
public WeatherKind Kind => _kind;
|
||||
|
||||
/// <summary>Last-known server fog override (sticky between sync packets).</summary>
|
||||
public EnvironOverride Override
|
||||
{
|
||||
get => _override;
|
||||
set => _override = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Debug / test hook — force a specific weather kind, ignoring the
|
||||
/// per-day roll. Passing <see cref="WeatherKind.Clear"/> returns to
|
||||
/// normal behavior starting on the next day-roll.
|
||||
/// </summary>
|
||||
public void ForceWeather(WeatherKind kind)
|
||||
{
|
||||
BeginTransition(kind);
|
||||
_rolledDayIndex = int.MaxValue; // "forced" sentinel — don't re-roll
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advance the state machine. Call once per frame from the render
|
||||
/// loop. <paramref name="dayIndex"/> is the in-game day (derived
|
||||
/// from <see cref="DerethDateTime"/>); when it changes we re-roll
|
||||
/// the weather kind.
|
||||
/// </summary>
|
||||
public void Tick(double nowSeconds, int dayIndex, float dtSeconds)
|
||||
{
|
||||
// Cross-fade progression: transitionT advances toward 1 over
|
||||
// TransitionSeconds. Capped; no further rollover.
|
||||
if (_transitionT < 1f)
|
||||
_transitionT = Math.Min(1f, _transitionT + dtSeconds / TransitionSeconds);
|
||||
|
||||
// Day changed → re-roll. Skip the sentinel (forced).
|
||||
if (dayIndex != _rolledDayIndex && _rolledDayIndex != int.MaxValue)
|
||||
{
|
||||
_rolledDayIndex = dayIndex;
|
||||
var newKind = RollKind(dayIndex);
|
||||
if (newKind != _kind) BeginTransition(newKind);
|
||||
}
|
||||
|
||||
// Lightning timer only ticks in Storm kind.
|
||||
if (_kind == WeatherKind.Storm && _override == EnvironOverride.None)
|
||||
{
|
||||
_nextStrikeInS -= dtSeconds;
|
||||
if (_nextStrikeInS <= 0f)
|
||||
{
|
||||
TriggerFlash();
|
||||
_nextStrikeInS = StrikeIntervalMinS
|
||||
+ (float)_strikeJitter.NextDouble() * (StrikeIntervalMaxS - StrikeIntervalMinS);
|
||||
}
|
||||
}
|
||||
|
||||
// Decay the flash level with a 200ms time constant.
|
||||
if (_flashLevel > 0f)
|
||||
{
|
||||
_flashAge += dtSeconds;
|
||||
if (_flashAge < FlashPeakHoldS)
|
||||
_flashLevel = 1f;
|
||||
else
|
||||
_flashLevel = MathF.Exp(-(_flashAge - FlashPeakHoldS) * FlashDecay);
|
||||
if (_flashLevel < 1e-3f) _flashLevel = 0f;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trigger a lightning flash manually (server-forced or test hook).
|
||||
/// </summary>
|
||||
public void TriggerFlash()
|
||||
{
|
||||
_flashLevel = 1f;
|
||||
_flashAge = 0f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Produce the per-frame snapshot consumed by the shader UBO +
|
||||
/// particle emitter spawners. Combines the sky keyframe's fog with
|
||||
/// the weather state's fog overlay, then applies the server
|
||||
/// <see cref="EnvironOverride"/> tint if any.
|
||||
/// </summary>
|
||||
public AtmosphereSnapshot Snapshot(in SkyKeyframe kf)
|
||||
{
|
||||
// Cross-fade fog distance + color from previous-kind to new-kind.
|
||||
var prev = FogForKind(_previousKind, kf);
|
||||
var curr = FogForKind(_kind, kf);
|
||||
|
||||
float t = _transitionT;
|
||||
var fogColor = Vector3.Lerp(prev.color, curr.color, t);
|
||||
float fogStart = prev.start + (curr.start - prev.start) * t;
|
||||
float fogEnd = prev.end + (curr.end - prev.end) * t;
|
||||
|
||||
// Server environ override wins.
|
||||
if (_override != EnvironOverride.None)
|
||||
{
|
||||
fogColor = EnvironOverrideColor(_override);
|
||||
fogStart = 15f;
|
||||
fogEnd = 80f; // Dense override fog
|
||||
}
|
||||
|
||||
float intensity = _kind == WeatherKind.Clear ? 1f - t : t;
|
||||
|
||||
return new AtmosphereSnapshot(
|
||||
Kind: _kind,
|
||||
Intensity: Math.Clamp(intensity, 0f, 1f),
|
||||
FogColor: fogColor,
|
||||
FogStart: fogStart,
|
||||
FogEnd: fogEnd,
|
||||
FogMode: kf.FogMode,
|
||||
LightningFlash: _flashLevel,
|
||||
Override: _override);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Internal machinery
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
private void BeginTransition(WeatherKind newKind)
|
||||
{
|
||||
_previousKind = _kind;
|
||||
_kind = newKind;
|
||||
_transitionT = 0f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic per-day weighted roll. Seeded with <paramref name="dayIndex"/>
|
||||
/// alone so every client running the same day sees the same weather —
|
||||
/// retail's mechanism for "synchronized weather without any packets"
|
||||
/// (r12 §6.1).
|
||||
/// </summary>
|
||||
private static WeatherKind RollKind(int dayIndex)
|
||||
{
|
||||
// Mix the day index so consecutive days aren't adjacent in PRNG
|
||||
// state space (avoids tiny-seed correlation issues).
|
||||
int seed = unchecked((int)((uint)dayIndex * 0x9E3779B1u));
|
||||
var rng = new Random(seed);
|
||||
double r = rng.NextDouble();
|
||||
// Retail weights (approximate): 60% clear, 20% overcast, 12% rain,
|
||||
// 5% snow, 3% storm. Tuned for "most days are fine, some are bad."
|
||||
if (r < 0.60) return WeatherKind.Clear;
|
||||
if (r < 0.80) return WeatherKind.Overcast;
|
||||
if (r < 0.92) return WeatherKind.Rain;
|
||||
if (r < 0.97) return WeatherKind.Snow;
|
||||
return WeatherKind.Storm;
|
||||
}
|
||||
|
||||
private static (Vector3 color, float start, float end) FogForKind(WeatherKind kind, in SkyKeyframe kf)
|
||||
{
|
||||
return kind switch
|
||||
{
|
||||
WeatherKind.Clear => (kf.FogColor, kf.FogStart, kf.FogEnd),
|
||||
WeatherKind.Overcast => (Vector3.Lerp(kf.FogColor, new Vector3(0.55f, 0.55f, 0.55f), 0.6f),
|
||||
OvercastFogStart, OvercastFogEnd),
|
||||
WeatherKind.Rain => (Vector3.Lerp(kf.FogColor, new Vector3(0.45f, 0.48f, 0.55f), 0.7f),
|
||||
OvercastFogStart, OvercastFogEnd),
|
||||
WeatherKind.Snow => (Vector3.Lerp(kf.FogColor, new Vector3(0.80f, 0.82f, 0.90f), 0.6f),
|
||||
OvercastFogStart, OvercastFogEnd * 1.2f),
|
||||
WeatherKind.Storm => (Vector3.Lerp(kf.FogColor, new Vector3(0.25f, 0.25f, 0.30f), 0.8f),
|
||||
StormFogStart, StormFogEnd),
|
||||
_ => (kf.FogColor, kf.FogStart, kf.FogEnd),
|
||||
};
|
||||
}
|
||||
|
||||
private static Vector3 EnvironOverrideColor(EnvironOverride o) => o switch
|
||||
{
|
||||
EnvironOverride.RedFog => new Vector3(0.60f, 0.05f, 0.05f),
|
||||
EnvironOverride.BlueFog => new Vector3(0.08f, 0.15f, 0.60f),
|
||||
EnvironOverride.WhiteFog => new Vector3(0.90f, 0.90f, 0.92f),
|
||||
EnvironOverride.GreenFog => new Vector3(0.08f, 0.55f, 0.12f),
|
||||
EnvironOverride.BlackFog => new Vector3(0.02f, 0.02f, 0.02f),
|
||||
EnvironOverride.BlackFog2 => new Vector3(0.04f, 0.01f, 0.01f),
|
||||
_ => new Vector3(1f, 1f, 1f),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue