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:
Erik 2026-04-19 10:56:49 +02:00
commit 48b5e1f1b1
31 changed files with 3057 additions and 129 deletions

View 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, &gt;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;
}
}

View 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;
}
}

View 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;
}
}
}

View 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 &lt; End</c> → daytime arc, visible in [Begin, End].</description></item>
/// <item><description><c>Begin &gt; 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);
}
}

View file

@ -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);

View 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.16.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 830 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: 830 seconds between strikes.
private const float StrikeIntervalMinS = 8f;
private const float StrikeIntervalMaxS = 30f;
// Overcast-kind fog feels like ~40150m 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),
};
}