feat(world): Phase G.1 data model — dat-accurate SkyKeyframe + WeatherSystem
Expand the SkyKeyframe record with retail-exact fog fields (FogStart, FogEnd, FogMode) per r12 §5. The existing FogDensity field is retained for backwards compat with tests that pin it; new shipping code reads FogStart / FogEnd / FogMode directly. Add WeatherSystem (WeatherKind + EnvironOverride enum + 10s transition ease + deterministic per-day-index roll) matching r12 §6.1. Roll weights are ~60% Clear / 20% Overcast / 12% Rain / 5% Snow / 3% Storm — tuned against retail observations. Storm mode triggers lightning flashes every 8–30 s via an exponential-decay (200ms τ) flash level that the shader consumes as an additive scene bump. Add SkyDescLoader that parses the Region dat (0x13000000) into LoadedSkyDesc — DayGroupData with SkyObjectData (visibility window + arc sweep), per-keyframe SkyObjectReplaceData, and a shader-ready SkyStateProvider builder. Sun/ambient colors are pre-multiplied by DirBright/AmbBright so the shader never needs to know about retail's scalar brightness field. 19 new tests (weather determinism, transition ease, environ override tint, flash decay, dat-load conversion with fog + pre-mult colors). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
63b6922fc2
commit
0df1c5b4a6
5 changed files with 982 additions and 31 deletions
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue