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
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue