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
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