Merge branch 'feature/sky-fixes' — sky/weather rendering retail-faithful pass
Six commits on the branch, three retail-decomp investigations (in-house + two external code-review agents) converging on the same root causes:97fc1b5fix(sky): translucency-as-opacity + sky fog floor + additive fog-skip05a8a72fix(sky): retail-faithful sun-vector magnitude for SunColor / AmbientColor034a684fix(sky): partition sky pass on Properties bit 0x01, not bit 0x04375065bfix(meshing): Translucent flag overrides Additive blend per retail SetSurface646cccafeat(sky): load Setup-backed (0x020xxx) sky objects via SetupMesh.Flatten0c82d2cdocs(issues): #28 root-caused (PES particles), #29 filed Net effect: * Sun + ambient colors now use retail's |sunVec| magnitude formula from PrimD3DRender::UpdateLightsInternal at decomp 424118 — fixes blue-white sky tint at most keyframes. * Surface.Translucency is used DIRECTLY as opacity (not 1-x) per D3DPolyRender::SetSurface at decomp 425255 — fixes 3× too-bright cloud + correct rain alpha. * Sky fog re-enabled with SKY_FOG_FLOOR=0.2 mitigation — horizon haze visible without flat-fogging the dome at storm keyframes. * Additive surfaces skip fog per SetFFFogAlphaDisabled at decomp 425295 — sun stays bright at horizon dusk/dawn. * Pre/post-scene partition is bit 0x01 (post-scene placement) instead of bit 0x04 (weather gate), per GameSky::CreateDeletePhysicsObjects at decomp 269036. Fixes double-rendered foreground rain. * Translucent flag forces alpha-blend over Additive when ClipMap is set, matching retail's blend resolution at decomp 425246-425260. Cloud surface 0x08000023 now classified correctly. * Setup-backed sky objects (0x020xxxxx) now load via SetupMesh.Flatten instead of being silently dropped by EnsureMeshUploaded. Tests: 1227 pass. User-visible improvements: foreground rain matches retail's volumetric look, sky tint shifted from blue-white toward retail's warm-gray, additive sun stays bright through horizon haze. Outstanding: * Issue #28 — PES particle rendering ("aurora light play"). Now root-caused with implementation outline; defer to its own Phase. * Issue #29 — residual cloud-density gap; likely rolls into #28. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> # Conflicts: # src/AcDream.App/Rendering/GameWindow.cs
This commit is contained in:
commit
f7c9e88b6a
18 changed files with 1439 additions and 290 deletions
|
|
@ -373,12 +373,6 @@ public sealed class GameWindow : IDisposable
|
|||
private long _loadedSkyDayIndex = long.MinValue;
|
||||
private AcDream.Core.World.DayGroupData? _activeDayGroup;
|
||||
|
||||
// Current rain/snow emitter handles — spawned on weather-kind change
|
||||
// and stopped when the kind leaves Rain/Snow. Non-zero == active.
|
||||
private int _rainEmitterHandle;
|
||||
private int _snowEmitterHandle;
|
||||
private AcDream.Core.World.WeatherKind _lastWeatherKind =
|
||||
AcDream.Core.World.WeatherKind.Clear;
|
||||
private double _weatherAccum;
|
||||
|
||||
// F7 / F10 debug-cycle steps for time + weather. Initialized out of
|
||||
|
|
@ -4371,10 +4365,19 @@ public sealed class GameWindow : IDisposable
|
|||
Weather.Tick(nowSeconds: _weatherAccum, dayIndex: dayIndex, dtSeconds: (float)deltaSeconds);
|
||||
_weatherAccum += deltaSeconds;
|
||||
|
||||
// Update the rain/snow particle emitters when the weather kind
|
||||
// changes. Keep the emitters fed by the ParticleSystem tick so
|
||||
// visuals stay alive frame-over-frame.
|
||||
UpdateWeatherParticles(atmo);
|
||||
// (Pre-Bug-A code spawned camera-attached rain/snow particle
|
||||
// emitters here as a workaround for missing weather-mesh
|
||||
// rendering. Deleted 2026-04-26 once the retail-faithful world-
|
||||
// space mesh path landed in SkyRenderer.RenderWeather. Retail
|
||||
// rain is GfxObj 0x01004C42/0x01004C44 — a hollow octagonal
|
||||
// cylinder anchored at player_pos + (0, 0, -120m) per
|
||||
// GameSky::UpdatePosition at 0x00506dd0 — drawn after the
|
||||
// landblock pass per LScape::draw at 0x00506330. There is no
|
||||
// server-driven weather event and no camera-attached emitter
|
||||
// in retail. Snow renders identically when a Snowy DayGroup is
|
||||
// active in some other Region; the partition by Properties&0x04
|
||||
// and the SkyRenderer.RenderWeather pass both pick up snow
|
||||
// weather meshes for free.)
|
||||
|
||||
// Phase E.3: advance live particle emitters AFTER animation tick
|
||||
// so emitters spawned by hooks fired this frame get integrated.
|
||||
|
|
@ -4476,9 +4479,17 @@ public sealed class GameWindow : IDisposable
|
|||
// celestial meshes FIRST so the rest of the scene z-tests
|
||||
// on top of them (depth mask off, no depth writes). Skipped
|
||||
// when indoors; dungeons fully block sky visibility.
|
||||
//
|
||||
// Mirrors retail's LScape::draw at 0x00506330 which calls
|
||||
// GameSky::Draw(0) (sky pass) BEFORE the landblock DrawBlock
|
||||
// loop and GameSky::Draw(1) (weather pass) AFTER. The split
|
||||
// matters because weather meshes (the 815m-tall rain
|
||||
// cylinder 0x01004C42/0x01004C44) need to overlay terrain
|
||||
// and entities to look volumetric — see the post-scene
|
||||
// RenderWeather call further below.
|
||||
if (!cameraInsideCell)
|
||||
{
|
||||
_skyRenderer?.Render(camera, camPos, (float)WorldTime.DayFraction,
|
||||
_skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction,
|
||||
_activeDayGroup, kf);
|
||||
}
|
||||
|
||||
|
|
@ -4514,6 +4525,20 @@ public sealed class GameWindow : IDisposable
|
|||
if (_particleSystem is not null && _particleRenderer is not null)
|
||||
_particleRenderer.Draw(_particleSystem, camera, camPos);
|
||||
|
||||
// Bug A fix (post-#26 worktree, 2026-04-26): weather sky
|
||||
// meshes (Properties & 0x04, e.g. the 815m-tall rain
|
||||
// cylinder 0x01004C42/0x01004C44) render AFTER the scene so
|
||||
// the additive rain streaks overlay terrain and entities
|
||||
// instead of being painted over by them. This is the second
|
||||
// half of retail's LScape::draw split — GameSky::Draw(1)
|
||||
// fires after the DrawBlock loop. Same indoor gate as the
|
||||
// sky pass: weather is suppressed inside cells.
|
||||
if (!cameraInsideCell)
|
||||
{
|
||||
_skyRenderer?.RenderWeather(camera, camPos, (float)WorldTime.DayFraction,
|
||||
_activeDayGroup, kf);
|
||||
}
|
||||
|
||||
// Debug: draw collision shapes as wireframe cylinders around the
|
||||
// player so we can visually verify alignment with scenery meshes.
|
||||
if (_debugCollisionVisible && _debugLines is not null)
|
||||
|
|
@ -4730,12 +4755,28 @@ public sealed class GameWindow : IDisposable
|
|||
// title bar. Default is true (matches pre-L.0 behaviour);
|
||||
// unchecking the toggle in Display tab collapses the title
|
||||
// to just "acdream" for a cleaner alt-tab experience.
|
||||
//
|
||||
// When perf is shown, also include the in-game calendar/time —
|
||||
// matches retail's @timestamp output ("Date: <Month> <Day>,
|
||||
// PY <Year> Time: <HourName>"). Uses NowTicks (server-synced
|
||||
// + wall-clock interpolation) so the user can read the same
|
||||
// fields off both acdream and retail and confirm clock parity
|
||||
// directly. Drift > 1 hour = real bug.
|
||||
bool showFps = _settingsVm?.DisplayDraft.ShowFps ?? true;
|
||||
_window!.Title = showFps
|
||||
? $"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | "
|
||||
+ $"lb {visibleLandblocks}/{totalLandblocks} visible | "
|
||||
+ $"ent {entityCount} | anim {animatedCount}"
|
||||
: "acdream";
|
||||
if (showFps)
|
||||
{
|
||||
double tNow = WorldTime.NowTicks;
|
||||
var titleCal = AcDream.Core.World.DerethDateTime.ToCalendar(tNow);
|
||||
double df = WorldTime.DayFraction;
|
||||
_window!.Title =
|
||||
$"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | "
|
||||
+ $"lb {visibleLandblocks}/{totalLandblocks} | ent {entityCount}/anim {animatedCount} | "
|
||||
+ $"PY{titleCal.Year} {titleCal.Month} {titleCal.Day} {titleCal.Hour} (df={df:F4})";
|
||||
}
|
||||
else
|
||||
{
|
||||
_window!.Title = "acdream";
|
||||
}
|
||||
_lastFps = fps;
|
||||
_lastFrameMs = avgFrameTime;
|
||||
_perfAccum = 0;
|
||||
|
|
@ -5393,9 +5434,11 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
else
|
||||
{
|
||||
// Outdoor: full keyframe sun + ambient; colors are already
|
||||
// pre-multiplied by DirBright / AmbBright inside
|
||||
// SkyDescLoader so we feed them straight into the UBO.
|
||||
// Outdoor: full keyframe sun + ambient. The SkyKeyframe stores
|
||||
// raw DirColor + DirBright (and AmbColor + AmbBright) for
|
||||
// retail-faithful per-channel keyframe interpolation; the
|
||||
// computed `kf.SunColor` / `kf.AmbientColor` properties return
|
||||
// the post-multiplied product the shader expects.
|
||||
Lighting.Sun = new AcDream.Core.Lighting.LightSource
|
||||
{
|
||||
Kind = AcDream.Core.Lighting.LightKind.Directional,
|
||||
|
|
@ -5411,114 +5454,6 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keep the rain/snow camera-anchored emitters aligned with the
|
||||
/// current weather state. Spawns on entry, stops on exit, with no
|
||||
/// per-frame churn while the state is stable. Emitters are camera-
|
||||
/// local (<see cref="AcDream.Core.Vfx.EmitterFlags.AttachLocal"/>)
|
||||
/// so walking never leaves the rain volume (r12 §7).
|
||||
/// </summary>
|
||||
private void UpdateWeatherParticles(in AcDream.Core.World.AtmosphereSnapshot atmo)
|
||||
{
|
||||
if (_particleSystem is null) return;
|
||||
|
||||
if (atmo.Kind == _lastWeatherKind) return; // no change
|
||||
|
||||
// Stop any existing emitters first.
|
||||
if (_rainEmitterHandle != 0)
|
||||
{
|
||||
_particleSystem.StopEmitter(_rainEmitterHandle, fadeOut: true);
|
||||
_rainEmitterHandle = 0;
|
||||
}
|
||||
if (_snowEmitterHandle != 0)
|
||||
{
|
||||
_particleSystem.StopEmitter(_snowEmitterHandle, fadeOut: true);
|
||||
_snowEmitterHandle = 0;
|
||||
}
|
||||
|
||||
// Anchor at camera world position; AttachLocal keeps it moving.
|
||||
var anchor = System.Numerics.Vector3.Zero;
|
||||
if (_cameraController is not null)
|
||||
{
|
||||
System.Numerics.Matrix4x4.Invert(_cameraController.Active.View, out var inv);
|
||||
anchor = new System.Numerics.Vector3(inv.M41, inv.M42, inv.M43);
|
||||
}
|
||||
|
||||
switch (atmo.Kind)
|
||||
{
|
||||
case AcDream.Core.World.WeatherKind.Rain:
|
||||
case AcDream.Core.World.WeatherKind.Storm:
|
||||
_rainEmitterHandle = _particleSystem.SpawnEmitter(
|
||||
BuildRainDesc(), anchor);
|
||||
break;
|
||||
case AcDream.Core.World.WeatherKind.Snow:
|
||||
_snowEmitterHandle = _particleSystem.SpawnEmitter(
|
||||
BuildSnowDesc(), anchor);
|
||||
break;
|
||||
}
|
||||
|
||||
_lastWeatherKind = atmo.Kind;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rain emitter tuned per r12 §7: streaks falling at ~50 m/s with
|
||||
/// a slight wind bias, 500 drops/sec, 2000 max alive, 1.2s life so
|
||||
/// drops cover the ~60m fall at terminal velocity.
|
||||
/// </summary>
|
||||
private static AcDream.Core.Vfx.EmitterDesc BuildRainDesc() => new()
|
||||
{
|
||||
DatId = 0xFFFF_0001u, // synthetic id
|
||||
Type = AcDream.Core.Vfx.ParticleType.LocalVelocity,
|
||||
Flags = AcDream.Core.Vfx.EmitterFlags.AttachLocal |
|
||||
AcDream.Core.Vfx.EmitterFlags.Billboard,
|
||||
EmitRate = 500f,
|
||||
MaxParticles = 2000,
|
||||
LifetimeMin = 1.0f,
|
||||
LifetimeMax = 1.4f,
|
||||
OffsetDir = new System.Numerics.Vector3(0, 0, 1),
|
||||
MinOffset = 0f,
|
||||
MaxOffset = 50f,
|
||||
SpawnDiskRadius = 15f,
|
||||
InitialVelocity = new System.Numerics.Vector3(0.5f, 0f, -50f),
|
||||
VelocityJitter = 2f,
|
||||
Gravity = System.Numerics.Vector3.Zero,
|
||||
StartColorArgb = 0x40B0C0E0u,
|
||||
EndColorArgb = 0x20B0C0E0u,
|
||||
StartAlpha = 0.3f,
|
||||
EndAlpha = 0f,
|
||||
StartSize = 0.05f,
|
||||
EndSize = 0.05f,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Snow emitter tuned per r12 §8: slow fall at ~2 m/s, tumbling
|
||||
/// sideways drift, small billboards, 100 flakes/sec, long lifespan.
|
||||
/// </summary>
|
||||
private static AcDream.Core.Vfx.EmitterDesc BuildSnowDesc() => new()
|
||||
{
|
||||
DatId = 0xFFFF_0002u,
|
||||
Type = AcDream.Core.Vfx.ParticleType.LocalVelocity,
|
||||
Flags = AcDream.Core.Vfx.EmitterFlags.AttachLocal |
|
||||
AcDream.Core.Vfx.EmitterFlags.Billboard,
|
||||
EmitRate = 100f,
|
||||
MaxParticles = 1000,
|
||||
LifetimeMin = 4f,
|
||||
LifetimeMax = 8f,
|
||||
OffsetDir = new System.Numerics.Vector3(0, 0, 1),
|
||||
MinOffset = 0f,
|
||||
MaxOffset = 30f,
|
||||
SpawnDiskRadius = 15f,
|
||||
InitialVelocity = new System.Numerics.Vector3(0.3f, 0.2f, -2f),
|
||||
VelocityJitter = 0.8f,
|
||||
Gravity = System.Numerics.Vector3.Zero,
|
||||
StartColorArgb = 0xE0FFFFFFu,
|
||||
EndColorArgb = 0x80FFFFFFu,
|
||||
StartAlpha = 0.85f,
|
||||
EndAlpha = 0.3f,
|
||||
StartSize = 0.08f,
|
||||
EndSize = 0.06f,
|
||||
};
|
||||
|
||||
// ── Phase I.2 — DebugPanel helpers ────────────────────────────────
|
||||
//
|
||||
// The ImGui DebugPanel reads through DebugVM closures that ask
|
||||
|
|
|
|||
|
|
@ -2,17 +2,16 @@
|
|||
// Sky mesh fragment shader — final composite matching retail's
|
||||
// D3D fixed-function:
|
||||
//
|
||||
// fragment.rgb = texture.rgb × vTint × uLuminosity + lightning_flash
|
||||
// fragment.a = texture.a × (1 - uTransparency)
|
||||
// fragment.rgb = texture.rgb × vTint + lightning_flash
|
||||
// fragment.a = texture.a × (1 - uTransparency) × (1 - uSurfTranslucency)
|
||||
//
|
||||
// vTint arrives from the vertex shader with retail's per-vertex
|
||||
// lighting formula baked in (Emissive + lightAmbient + lightDiffuse ×
|
||||
// max(N·L, 0)) — see sky.vert for the decompile citation.
|
||||
//
|
||||
// uLuminosity is the per-keyframe SkyObjectReplace.Luminosity override
|
||||
// (0..1, /100 in SkyDescLoader). It's a SEPARATE field from the
|
||||
// Surface.Luminosity that feeds uEmissive in the vertex shader — they
|
||||
// compose multiplicatively in retail too.
|
||||
// max(N·L, 0)) — see sky.vert for the decompile citation. The keyframe
|
||||
// SkyObjectReplace.Luminosity override is folded into uEmissive on the
|
||||
// CPU side (SkyRenderer.cs) so vTint already saturates properly for
|
||||
// bright keyframes; the previous shader had a redundant uLuminosity
|
||||
// multiply that was double-dimming clouds, removed 2026-04-26.
|
||||
//
|
||||
// See `docs/research/2026-04-23-sky-material-state.md`.
|
||||
|
||||
|
|
@ -22,8 +21,20 @@ in float vFogFactor; // 1 = no fog (near), 0 = full fog color (far)
|
|||
out vec4 fragColor;
|
||||
|
||||
uniform sampler2D uDiffuse;
|
||||
uniform float uTransparency; // 0 = fully visible, 1 = fully transparent
|
||||
uniform float uLuminosity; // SkyObjectReplace.Luminosity override (0..1)
|
||||
uniform float uTransparency; // 0 = fully visible, 1 = fully transparent
|
||||
// 1.0 = apply fog mix to this submesh; 0.0 = skip fog (Additive sky
|
||||
// surfaces — sun/moon/stars per retail SetFFFogAlphaDisabled(1) at
|
||||
// D3DPolyRender::SetSurface 0x59c882). Set per-submesh on the CPU side.
|
||||
uniform float uApplyFog;
|
||||
// Surface.Translucency float (0..1) used DIRECTLY as opacity (NOT 1-x).
|
||||
// Distinct from uTransparency (per-keyframe Replace override). Retail
|
||||
// D3DPolyRender::SetSurface at 0x59c7a6 (decomp 425255-425260) reads
|
||||
// Surface.Translucency when the Translucent (0x10) bit is set and feeds
|
||||
// _ftol2(translucency × 255) directly as vertex alpha. ACViewer
|
||||
// (TextureCache.cs:142) + WorldBuilder (ObjectMeshManager.cs:1115) both
|
||||
// invert it (1-x) and are wrong. For non-Translucent surfaces the CPU
|
||||
// side (GfxObjMesh.Build) sets uSurfTranslucency = 1.0 ⇒ no effect.
|
||||
uniform float uSurfTranslucency;
|
||||
|
||||
// Shared SceneLighting UBO — fog params drive the mix, flash channel
|
||||
// bumps sky brightness during lightning strikes. Matches sky.vert's
|
||||
|
|
@ -45,24 +56,45 @@ layout(std140, binding = 1) uniform SceneLighting {
|
|||
void main() {
|
||||
vec4 sampled = texture(uDiffuse, vTex);
|
||||
|
||||
// Composite: texture × per-vertex lit.
|
||||
// `rep.Luminosity` is now pushed into `uEmissive` on the CPU side
|
||||
// (SkyRenderer.cs) so `vTint` already saturates properly for bright
|
||||
// keyframes. Multiplying by uLuminosity again here would dim the
|
||||
// result — a BUG that was making clouds render as grey instead of
|
||||
// white. Retail's fragment formula (FUN_0059da60 non-luminous
|
||||
// branch) is texture × litColor × vertex.color(=white), so just
|
||||
// `texture × vTint` is the retail-faithful composite.
|
||||
// Composite: texture × per-vertex lit. Replace.Luminosity (per
|
||||
// keyframe) and Surface.Luminosity are both folded into uEmissive
|
||||
// on the CPU side (SkyRenderer.cs) so vTint already carries the
|
||||
// right tint for the time-of-day. Retail's fragment formula
|
||||
// (FUN_0059da60 non-luminous branch) is texture × litColor ×
|
||||
// vertex.color(=white), so `texture × vTint` is the retail-faithful
|
||||
// composite.
|
||||
vec3 rgb = sampled.rgb * vTint;
|
||||
|
||||
// Retail vertex fog: lerp(fogColor, scene, fogFactor). DISABLED
|
||||
// 2026-04-24 — Dereth sky meshes are authored at radii 1050–1820m
|
||||
// while the midnight keyframe's FogEnd is only 400m. Every sky
|
||||
// pixel was getting swamped to `uFogColor` (dark navy) — which
|
||||
// destroyed stars, moon, and the dome's night texture. Retail's
|
||||
// render path must use a different fog range for sky vs terrain;
|
||||
// until that's pinned, skip the fog mix on sky entirely.
|
||||
// rgb = mix(uFogColor.rgb, rgb, vFogFactor);
|
||||
// Retail-faithful sky fog mix with a "fog floor" mitigation:
|
||||
//
|
||||
// Dereth sky meshes are authored at radii 1050–1820m. At midnight
|
||||
// (storm keyframes FogEnd ~400m) the raw vFogFactor saturates to 0
|
||||
// for every dome pixel — `mix(fogColor, rgb, 0)` would render the
|
||||
// entire dome as flat fogColor, destroying stars / moon / texture.
|
||||
// That was the reason fog was disabled on sky 2026-04-24 (issue #4).
|
||||
//
|
||||
// Retail clearly DOES apply fog to its sky meshes — distant horizon
|
||||
// mountains and the dome itself fade toward the fog color in retail
|
||||
// screenshots. Mechanism unknown (sky-specific FogEnd? elevation-
|
||||
// weighted? different formula?). Until pinned, the workaround is
|
||||
// a clamp on the minimum fog factor so the dome NEVER mixes more
|
||||
// than (1 - SKY_FOG_FLOOR) toward fogColor — preserves stars/moon
|
||||
// while still letting the horizon haze visibly in low-FogEnd
|
||||
// keyframes.
|
||||
//
|
||||
// SKY_FOG_FLOOR=0.2 means dome shows AT LEAST 20% raw texture, AT
|
||||
// MOST 80% fog color even at extreme distances. Tuned via dual-
|
||||
// client visual comparison 2026-04-27 — adjust if night sky goes
|
||||
// back to flat-fog or stays too vivid vs retail.
|
||||
// Skip fog mix entirely on Additive surfaces (sun, moon, stars,
|
||||
// additive cloud sheets) — retail's SetFFFogAlphaDisabled(1) at
|
||||
// D3DPolyRender::SetSurface 0x59c882. Without this gate the sun
|
||||
// dims to fog color at horizon, which doesn't match retail.
|
||||
if (uApplyFog > 0.5) {
|
||||
const float SKY_FOG_FLOOR = 0.2;
|
||||
float skyFogFactor = max(vFogFactor, SKY_FOG_FLOOR);
|
||||
rgb = mix(uFogColor.rgb, rgb, skyFogFactor);
|
||||
}
|
||||
|
||||
// Lightning additive bump — client-driven during storm flashes.
|
||||
// NOTE: the exact retail mechanism for lightning visual is still
|
||||
|
|
@ -79,7 +111,24 @@ void main() {
|
|||
float cap = mix(1.0, 3.0, clamp(flash, 0.0, 1.0));
|
||||
rgb = min(rgb, vec3(cap));
|
||||
|
||||
float a = sampled.a * (1.0 - uTransparency);
|
||||
// Final fragment alpha:
|
||||
// uTransparency — keyframe-replace transparency override (0..1).
|
||||
// 0 = fully visible, 1 = fully transparent.
|
||||
// Applied as (1 - x).
|
||||
// uSurfTranslucency — the dat's Surface.Translucency value when the
|
||||
// Translucent flag is set, else 1.0. Despite the
|
||||
// name, retail uses this as OPACITY directly (per
|
||||
// D3DPolyRender::SetSurface at 0x59c7a6 which
|
||||
// writes _ftol2(translucency × 255) into vertex
|
||||
// alpha). Multiply directly — NOT (1 - x).
|
||||
//
|
||||
// For the rain mesh 0x01004C42/4C44 (translucency=0.5): a = 1*1*0.5 = 0.5
|
||||
// matches retail curr_alpha=127, halves the additive streak.
|
||||
// For cloud surface 0x08000023 (translucency=0.25): a = 1*1*0.25 = 0.25
|
||||
// matches retail curr_alpha=63, dim cloud (was 3× too bright with
|
||||
// the previous 1-x formula).
|
||||
// For non-Translucent surfaces uSurfTranslucency = 1.0, no effect.
|
||||
float a = sampled.a * (1.0 - uTransparency) * uSurfTranslucency;
|
||||
if (a < 0.01) discard;
|
||||
fragColor = vec4(rgb, a);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,8 +70,18 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draw the sky for this frame. Called FIRST in the render loop —
|
||||
/// terrain / meshes / debug lines / overlay land on top.
|
||||
/// Draw all NON-WEATHER sky objects (dome, sun, moon, stars, clouds —
|
||||
/// every <c>SkyObject</c> with <c>Properties & 0x04 == 0</c>).
|
||||
/// Called BEFORE the scene; terrain / meshes / debug lines / overlay
|
||||
/// land on top via depth-test.
|
||||
///
|
||||
/// <para>
|
||||
/// Mirrors the first half of retail's <c>LScape::draw</c> at
|
||||
/// <c>0x00506330</c>: that function calls <c>GameSky::Draw(0)</c>
|
||||
/// (sky pass) before the landblock loop, then <c>GameSky::Draw(1)</c>
|
||||
/// (weather pass) after. acdream splits the same way — see
|
||||
/// <see cref="RenderWeather"/> for the post-scene companion.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Each submesh renders with retail's per-vertex lighting formula:
|
||||
|
|
@ -91,12 +101,57 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
/// field.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void Render(
|
||||
public void RenderSky(
|
||||
ICamera camera,
|
||||
Vector3 cameraWorldPos,
|
||||
float dayFraction,
|
||||
DayGroupData? group,
|
||||
SkyKeyframe keyframe)
|
||||
=> RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, postScenePass: false);
|
||||
|
||||
/// <summary>
|
||||
/// Draw the POST-SCENE sky objects (the foreground rain mesh
|
||||
/// <c>0x01004C44</c> on Rainy DayGroups, plus any other SkyObject with
|
||||
/// <c>Properties & 0x01 != 0</c>). Called AFTER the scene so these
|
||||
/// meshes paint on top of terrain and entities — retail-faithful order
|
||||
/// from <c>LScape::draw</c> at <c>0x00506330</c>, where
|
||||
/// <c>GameSky::Draw(1)</c> fires after the <c>DrawBlock</c> loop and
|
||||
/// renders the <c>after_sky_cell</c> contents. With depth-test
|
||||
/// disabled and additive blend (the rain Surface flag includes
|
||||
/// Additive), the 815m-tall rain cylinder's bright streak texels add
|
||||
/// over the scene — making rain appear in the air between camera and
|
||||
/// character instead of only at the horizon.
|
||||
/// <para>
|
||||
/// Method name kept as <c>RenderWeather</c> for API stability; the
|
||||
/// pass actually partitions on <see cref="SkyObjectData.IsPostScene"/>
|
||||
/// (Properties bit <c>0x01</c>), not <see cref="SkyObjectData.IsWeather"/>
|
||||
/// (bit <c>0x04</c>). The two bits are independent in retail per
|
||||
/// <c>GameSky::CreateDeletePhysicsObjects</c> at <c>0x005073c0</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void RenderWeather(
|
||||
ICamera camera,
|
||||
Vector3 cameraWorldPos,
|
||||
float dayFraction,
|
||||
DayGroupData? group,
|
||||
SkyKeyframe keyframe)
|
||||
=> RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, postScenePass: true);
|
||||
|
||||
/// <summary>
|
||||
/// Shared pass for <see cref="RenderSky"/> and <see cref="RenderWeather"/>.
|
||||
/// Sets up the same GL state for both (depth-test off, additive +
|
||||
/// alpha-blend per submesh, camera-anchored translation) and iterates
|
||||
/// only the SkyObjects matching the requested partition by
|
||||
/// <see cref="SkyObjectData.IsPostScene"/> — bit <c>0x01</c> per the
|
||||
/// retail decomp at <c>GameSky::MakeObject</c> (<c>0x00506ee0</c>).
|
||||
/// </summary>
|
||||
private void RenderPass(
|
||||
ICamera camera,
|
||||
Vector3 cameraWorldPos,
|
||||
float dayFraction,
|
||||
DayGroupData? group,
|
||||
SkyKeyframe keyframe,
|
||||
bool postScenePass)
|
||||
{
|
||||
if (group is null || group.SkyObjects.Count == 0) return;
|
||||
|
||||
|
|
@ -149,20 +204,51 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
for (int i = 0; i < group.SkyObjects.Count; i++)
|
||||
{
|
||||
var obj = group.SkyObjects[i];
|
||||
// Partition by post-scene flag (Properties bit 0x01) — the
|
||||
// caller chose either the pre-scene sky pass (bit clear) or
|
||||
// the post-scene pass (bit set). Mirrors retail
|
||||
// GameSky::CreateDeletePhysicsObjects at 0x005073c0 / decomp
|
||||
// line 269036 which routes (Properties & 1) into
|
||||
// before_sky_cell vs after_sky_cell, and GameSky::Draw at
|
||||
// 0x00506ff0 which renders those cells in the two passes.
|
||||
// NOTE: bit 0x04 (IsWeather) is independent — it gates whether
|
||||
// the object is instantiated when weather_enabled is false.
|
||||
// Earlier acdream incorrectly used IsWeather for this
|
||||
// partition, putting the outer rain cylinder 0x01004C42
|
||||
// (Props=0x04, NO bit 0x01) into the post-scene pass with the
|
||||
// foreground rain — double-thick rain not matching retail.
|
||||
if (obj.IsPostScene != postScenePass) continue;
|
||||
if (!obj.IsVisible(dayFraction)) continue;
|
||||
|
||||
// Apply per-keyframe replace overrides.
|
||||
uint gfxObjId = obj.GfxObjId;
|
||||
float headingDeg = 0f;
|
||||
float transparent = 0f;
|
||||
float luminosity = 1f;
|
||||
// Replace-override luminosity. Stays NaN when there is no
|
||||
// replace entry or none of the keyframe's overrides are set,
|
||||
// and that NaN is the signal to fall back to the surface's
|
||||
// authored Luminosity at draw time. This replaces the previous
|
||||
// `luminosity = 1f` default which masked the surface value
|
||||
// because the `(luminosity > 0) ? luminosity : sub.SurfLuminosity`
|
||||
// fallback at the inner loop never fired (1f is always > 0).
|
||||
// RainMeshProbe (committed b8e0857) confirmed empirically that
|
||||
// NO Dereth sky surface carries the SurfaceType.Luminous flag
|
||||
// bit (0x40) — the differentiator is purely the float field.
|
||||
float replaceLuminosity = float.NaN;
|
||||
if (replaces.TryGetValue((uint)i, out var rep))
|
||||
{
|
||||
if (rep.GfxObjId != 0) gfxObjId = rep.GfxObjId;
|
||||
if (rep.Rotate != 0f) headingDeg = rep.Rotate;
|
||||
transparent = Math.Clamp(rep.Transparent, 0f, 1f);
|
||||
if (rep.Luminosity > 0f) luminosity = rep.Luminosity;
|
||||
if (rep.MaxBright > 0f) luminosity = MathF.Min(luminosity, rep.MaxBright);
|
||||
if (rep.Luminosity > 0f) replaceLuminosity = rep.Luminosity;
|
||||
// MaxBright is a CAP: even if the surface authored Lum=1.0,
|
||||
// a per-keyframe MaxBright trims it. When no explicit
|
||||
// Luminosity replace exists, MaxBright still acts as the
|
||||
// ceiling (applied against sub.SurfLuminosity at draw time).
|
||||
if (rep.MaxBright > 0f)
|
||||
replaceLuminosity = float.IsNaN(replaceLuminosity)
|
||||
? rep.MaxBright
|
||||
: MathF.Min(replaceLuminosity, rep.MaxBright);
|
||||
}
|
||||
if (gfxObjId == 0) continue;
|
||||
|
||||
|
|
@ -177,6 +263,26 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
* Matrix4x4.CreateRotationZ(-headingRad)
|
||||
* Matrix4x4.CreateRotationY(-rotationRad);
|
||||
|
||||
// Retail weather Z-offset (GameSky::UpdatePosition at
|
||||
// 0x00506dd0, decomp lines 0x506e96..0x506e98):
|
||||
//
|
||||
// if (((eax_13 & 4) != 0 && (eax_13 & 8) == 0))
|
||||
// int32_t var_4_1 = 0xc2f00000; // 0xc2f00000 == -120.0f
|
||||
//
|
||||
// Weather objects (property bit 0x04 set, bit 0x08 unset)
|
||||
// have their frame origin set to player_pos + (0, 0, -120m).
|
||||
// The rain cylinder GfxObjs 0x01004C42/0x01004C44 have local
|
||||
// Z range 0.11..814.90 (815m tall, 113m radius). Without the
|
||||
// offset the cylinder bottom sits at z=0.11 ABOVE the camera
|
||||
// (skyView translation is zeroed so model-origin == camera);
|
||||
// looking horizontally shows nothing, looking up shows a
|
||||
// distant cylinder. With -120m the cylinder spans z =
|
||||
// (camera-119.89)..(camera+694.90) in view space — camera
|
||||
// is inside, looking in any direction shows surrounding
|
||||
// walls — the volumetric foreground-rain look retail has.
|
||||
if (postScenePass)
|
||||
model = model * Matrix4x4.CreateTranslation(0f, 0f, -120f);
|
||||
|
||||
_shader.SetMatrix4("uModel", model);
|
||||
|
||||
// UV scroll accumulates real-time × velocity. Wrap to [0, 1]
|
||||
|
|
@ -186,7 +292,6 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
float vOffset = (obj.TexVelocityY * secondsSinceStart) % 1f;
|
||||
_shader.SetVec2("uUvScroll", new Vector2(uOffset, vOffset));
|
||||
_shader.SetFloat("uTransparency", transparent);
|
||||
_shader.SetFloat("uLuminosity", luminosity);
|
||||
|
||||
EnsureMeshUploaded(gfxObjId);
|
||||
if (!_gpuByGfxObj.TryGetValue(gfxObjId, out var subMeshes)) continue;
|
||||
|
|
@ -205,34 +310,85 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
else
|
||||
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
|
||||
|
||||
// Emissive source: retail's FUN_0059da60 for non-luminous
|
||||
// surfaces writes rep.Luminosity into D3DMATERIAL9.Emissive
|
||||
// (via material cache +0x3c). This PROMOTES bright-keyframe
|
||||
// clouds into the self-lit term so the litColor saturates
|
||||
// and the texture renders at full brightness rather than
|
||||
// being dimmed by a per-fragment multiply.
|
||||
// Emissive source picks the surface's authored Luminosity by
|
||||
// default; the per-keyframe replace data can OVERRIDE
|
||||
// (rep.Luminosity > 0) or CAP (rep.MaxBright). This matches
|
||||
// retail's FUN_0059da60: surface.Luminosity → D3DMATERIAL.Emissive
|
||||
// (via material cache +0x3c), with the keyframe replace
|
||||
// promoting bright-keyframe clouds when the keyframe asks.
|
||||
//
|
||||
// If no rep.Luminosity override: fall back to the Surface's
|
||||
// static Luminosity (1.0 for dome/sun/moon → saturates;
|
||||
// 0.0 for stars → stays ambient-lit, correct retail look).
|
||||
float effEmissive = (luminosity > 0f) ? luminosity : sub.SurfLuminosity;
|
||||
// Empirical Dereth sky surfaces (RainMeshProbe, b8e0857):
|
||||
// dome/sun/moon → Lum=1.0 → vTint saturates → texture
|
||||
// passthrough (correct retail look);
|
||||
// stars/clouds → Lum=0.0 → vTint = ambient + diffuse →
|
||||
// picks up the time-of-day tint;
|
||||
// rain → Lum=0.1484 → faint emissive baseline,
|
||||
// ambient+diffuse adds atmospheric tint.
|
||||
//
|
||||
// Pre-fix: the replace-override variable defaulted to 1f and
|
||||
// the fallback `(luminosity > 0) ? luminosity : sub.SurfLuminosity`
|
||||
// never fired — every sky mesh got effEmissive=1.0,
|
||||
// saturating vTint. That made stars/clouds look full-bright
|
||||
// instead of time-of-day-tinted, and made rain streaks
|
||||
// 6.7× too bright (one of two factors compounding the
|
||||
// foreground-rim visibility bug).
|
||||
float effEmissive = float.IsNaN(replaceLuminosity)
|
||||
? sub.SurfLuminosity
|
||||
: replaceLuminosity;
|
||||
_shader.SetFloat("uEmissive", effEmissive);
|
||||
|
||||
// Retail per-Surface translucency override (D3DPolyRender::SetSurface
|
||||
// at 0x59c7a6, decomp 425255-425260): when the Surface's
|
||||
// Translucent (0x10) bit is set, retail computes
|
||||
// curr_alpha = _ftol2(translucency × 255) and writes it as vertex
|
||||
// alpha — i.e. the dat's Translucency float is the OPACITY
|
||||
// directly, NOT inverted. ACViewer and WorldBuilder both invert
|
||||
// it (1 - x) and are wrong by the same misread. The shader uses
|
||||
// it directly as an opacity multiplier; for non-Translucent
|
||||
// surfaces the GfxObjMesh.Build() path keeps SurfTranslucency=1.0
|
||||
// (no effect). Critical for rain (Translucency=0.5 → opacity 0.5)
|
||||
// and clouds (Translucency=0.25 → opacity 0.25, dim like retail).
|
||||
_shader.SetFloat("uSurfTranslucency", sub.SurfTranslucency);
|
||||
|
||||
// Retail D3DPolyRender::SetSurface at 0x59c882 calls
|
||||
// SetFFFogAlphaDisabled(1) when the Additive flag (0x10000)
|
||||
// is set on the Surface — so the sun, moon, stars, and any
|
||||
// additive cloud sheet are drawn WITHOUT fog. Skipping fog
|
||||
// on additive surfaces keeps the sun bright at horizon
|
||||
// dusk/dawn (where fog would otherwise dim it to fog color).
|
||||
// Non-additive sky meshes (the dome, opaque cloud layers)
|
||||
// still mix toward fog with the floor mitigation in sky.frag.
|
||||
_shader.SetFloat("uApplyFog", sub.IsAdditive ? 0f : 1f);
|
||||
|
||||
uint tex = _textures.GetOrUpload(sub.SurfaceId);
|
||||
_gl.ActiveTexture(TextureUnit.Texture0);
|
||||
_gl.BindTexture(TextureTarget.Texture2D, tex);
|
||||
|
||||
// Sky meshes need per-object wrap mode. The dome is 5 flat
|
||||
// walls meeting at edges — under GL_REPEAT any UV drift
|
||||
// past [0,1] wraps to the opposite edge of the texture,
|
||||
// drawing a visible line along each wall seam. Static
|
||||
// sky GfxObjs (dome, sun, moon, stars) should use
|
||||
// CLAMP_TO_EDGE to avoid that bleed. Scrolling cloud
|
||||
// layers (TexVelocity != 0) still need REPEAT so the
|
||||
// animated UV offset wraps correctly. Detection heuristic:
|
||||
// non-zero TexVelocity on either axis ⇒ scrolling layer.
|
||||
bool scrolling = obj.TexVelocityX != 0f || obj.TexVelocityY != 0f;
|
||||
int wrapMode = scrolling
|
||||
// Sky meshes need per-object wrap mode driven by the
|
||||
// mesh's authored UV range, not by TexVelocity:
|
||||
// * The outer dome (0x010015EE/F0/F1/F2) authors UVs
|
||||
// strictly in [0,1]. Under GL_REPEAT the bilinear
|
||||
// filter at wall-seam edges would average a texel
|
||||
// near the right edge with one near the left edge of
|
||||
// the texture, drawing a visible "bleed line" along
|
||||
// every dome seam. CLAMP_TO_EDGE avoids that.
|
||||
// * The inner sky/star layer (0x010015EF) and the
|
||||
// cloud meshes (0x010015B6, 0x01004C36 etc) author
|
||||
// UVs that deliberately exceed [0,1] (~0.4..4.6) so
|
||||
// the texture tiles across the geometry. CLAMP_TO_EDGE
|
||||
// would clamp ~99% of the surface to a single edge
|
||||
// texel, leaving only a small "square" where UVs
|
||||
// happen to fall in [0,1] (Bug B in
|
||||
// docs/research/2026-04-26-sky-investigation-handoff.md).
|
||||
// The mesh builder pre-computes NeedsUvRepeat from the
|
||||
// actual UV range so the right answer is data-driven.
|
||||
// Scrolling clouds are also forced to REPEAT (the running
|
||||
// UV offset can drift outside [0,1] regardless of authored
|
||||
// range, and they'd show their own seam bleed otherwise).
|
||||
bool needsRepeat = sub.NeedsUvRepeat
|
||||
|| obj.TexVelocityX != 0f
|
||||
|| obj.TexVelocityY != 0f;
|
||||
int wrapMode = needsRepeat
|
||||
? (int)TextureWrapMode.Repeat
|
||||
: (int)TextureWrapMode.ClampToEdge;
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, wrapMode);
|
||||
|
|
@ -283,14 +439,53 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lazy GfxObj build — reuses <see cref="GfxObjMesh"/> so the
|
||||
/// pos/neg polygon splitting logic stays consistent with the main
|
||||
/// static-mesh pipeline. Most sky meshes are single-surface.
|
||||
/// Lazy mesh build for a sky object. Handles two cases:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>
|
||||
/// <c>0x010xxxxx</c> — direct <see cref="GfxObj"/>. Reuses
|
||||
/// <see cref="GfxObjMesh.Build"/> so the pos/neg polygon
|
||||
/// splitting logic stays consistent with the main static-mesh
|
||||
/// pipeline. Most sky meshes are single-surface.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <c>0x020xxxxx</c> — <see cref="Setup"/>. The agent at
|
||||
/// 2026-04-27 found these Setup-backed sky objects (e.g.
|
||||
/// <c>0x02000588</c>, <c>0x02000589</c>, <c>0x02000714</c>,
|
||||
/// <c>0x02000BA6</c>) were silently dropped: every cache miss
|
||||
/// fell into the GfxObj branch, returned null, and got cached
|
||||
/// as an empty submesh list. Per the named retail decomp
|
||||
/// <c>CPhysicsObj::InitPartArrayObject</c> at <c>0x0050ed40</c>
|
||||
/// dispatches type 7 to <c>CPartArray::CreateSetup</c>
|
||||
/// (decomp 280484) which loads the Setup and walks its parts.
|
||||
/// We mirror that here: <see cref="SetupMesh.Flatten"/> walks
|
||||
/// <c>Setup.Parts</c> at the default placement frame and
|
||||
/// <see cref="GfxObjMesh.Build"/> produces submeshes for each
|
||||
/// part. Per-part transforms are baked into vertex positions
|
||||
/// (sky setups are static — no animation needed for the static
|
||||
/// mesh half of the visual).
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Even with this fix the visible aurora-style sheen most retail
|
||||
/// rainy/cloudy setups produce comes from the <c>pes_id</c> field
|
||||
/// on each <see cref="DatReaderWriter.Types.SkyObject"/> (a Particle
|
||||
/// Effect Schedule) — that's a separate Phase-level feature.
|
||||
/// Rendering the Setup's static parts here is the geometry half;
|
||||
/// the dynamic particle half is deferred.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void EnsureMeshUploaded(uint gfxObjId)
|
||||
{
|
||||
if (_gpuByGfxObj.ContainsKey(gfxObjId)) return;
|
||||
|
||||
// Setup-backed sky object: walk Setup.Parts and bake per-part
|
||||
// transforms into the per-vertex positions. See doc comment above.
|
||||
if ((gfxObjId & 0xFF000000u) == 0x02000000u)
|
||||
{
|
||||
EnsureSetupUploaded(gfxObjId);
|
||||
return;
|
||||
}
|
||||
|
||||
// DatCollection isn't thread-safe and the streaming loader can be
|
||||
// actively reading a shared DatBinReader buffer; sky meshes are
|
||||
// loaded on the render thread but GfxObj.Unpack can race with the
|
||||
|
|
@ -331,6 +526,71 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
_gpuByGfxObj[gfxObjId] = gpuList;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Setup-backed sky object loader. Walks <see cref="Setup.Parts"/> at
|
||||
/// the default placement frame, builds submeshes via
|
||||
/// <see cref="GfxObjMesh.Build"/>, and bakes the per-part transform
|
||||
/// into the vertex positions before upload. Static-pose only — sky
|
||||
/// setups don't animate in any meaningful way for the visual we care
|
||||
/// about (the dynamic look comes from <c>pes_id</c> particles, not
|
||||
/// the underlying mesh).
|
||||
/// <para>
|
||||
/// Mirrors retail's <see cref="CPhysicsObj.InitPartArrayObject"/> at
|
||||
/// decomp <c>280484</c> dispatching type 7 → <c>CPartArray::CreateSetup</c>
|
||||
/// → <c>CSetup::SetSetupID</c>, which loads the setup and instantiates
|
||||
/// each part as a separate <c>CPhysicsObj</c> child. We collapse the
|
||||
/// children into a flat submesh list because the sky pass renders
|
||||
/// without per-part transforms anyway.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void EnsureSetupUploaded(uint setupId)
|
||||
{
|
||||
Setup? setup = null;
|
||||
try { setup = _dats.Get<Setup>(setupId); }
|
||||
catch { setup = null; }
|
||||
|
||||
if (setup is null)
|
||||
{
|
||||
_gpuByGfxObj[setupId] = new List<SubMeshGpu>();
|
||||
return;
|
||||
}
|
||||
|
||||
var parts = SetupMesh.Flatten(setup);
|
||||
var allSubs = new List<SubMeshGpu>(parts.Count);
|
||||
foreach (var partRef in parts)
|
||||
{
|
||||
GfxObj? partGfx = null;
|
||||
try { partGfx = _dats.Get<GfxObj>(partRef.GfxObjId); }
|
||||
catch { partGfx = null; }
|
||||
if (partGfx is null) continue;
|
||||
|
||||
System.Collections.Generic.IReadOnlyList<GfxObjSubMesh>? partSubs = null;
|
||||
try { partSubs = GfxObjMesh.Build(partGfx, _dats); }
|
||||
catch { partSubs = null; }
|
||||
if (partSubs is null) continue;
|
||||
|
||||
// Bake the part's local transform into the vertices. For sky
|
||||
// setups we don't expect non-uniform scale, so transforming
|
||||
// normals as directions is fine; if a future sky setup ever
|
||||
// breaks that assumption we'd need an inverse-transpose here.
|
||||
var partTx = partRef.PartTransform;
|
||||
foreach (var sub in partSubs)
|
||||
{
|
||||
var transformed = new Vertex[sub.Vertices.Length];
|
||||
for (int i = 0; i < sub.Vertices.Length; i++)
|
||||
{
|
||||
var v = sub.Vertices[i];
|
||||
var p = Vector3.Transform(v.Position, partTx);
|
||||
var n = Vector3.Normalize(Vector3.TransformNormal(v.Normal, partTx));
|
||||
transformed[i] = v with { Position = p, Normal = n };
|
||||
}
|
||||
var rebuilt = sub with { Vertices = transformed };
|
||||
allSubs.Add(UploadSubMesh(rebuilt));
|
||||
}
|
||||
}
|
||||
_gpuByGfxObj[setupId] = allSubs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Log each surface's raw flag bits and the derived
|
||||
/// <see cref="TranslucencyKind"/>. Called once per GfxObj when
|
||||
|
|
@ -423,6 +683,8 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
SurfaceId = sm.SurfaceId,
|
||||
IsAdditive = isAdditive,
|
||||
SurfLuminosity = sm.Luminosity,
|
||||
NeedsUvRepeat = sm.NeedsUvRepeat,
|
||||
SurfTranslucency = sm.SurfTranslucency,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -462,5 +724,26 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
/// <c>docs/research/2026-04-23-sky-retail-verbatim.md</c> §6.
|
||||
/// </summary>
|
||||
public float SurfLuminosity;
|
||||
/// <summary>
|
||||
/// True when the source mesh's authored UVs exceed [0,1] (e.g.
|
||||
/// the inner sky/star layer 0x010015EF and the cloud meshes —
|
||||
/// they tile their texture across the geometry). The renderer
|
||||
/// must use <c>GL_REPEAT</c> for these or only the small region
|
||||
/// where UVs fall in [0,1] samples the actual texture; the rest
|
||||
/// clamps to the edge texel ("square in one corner" symptom).
|
||||
/// Computed once at mesh build from the actual UV range.
|
||||
/// </summary>
|
||||
public bool NeedsUvRepeat;
|
||||
/// <summary>
|
||||
/// <c>Surface.Translucency</c> float (0..1) carried through from
|
||||
/// <see cref="GfxObjSubMesh.SurfTranslucency"/>. Passed to the
|
||||
/// sky fragment shader as <c>uSurfTranslucency</c>; the shader
|
||||
/// multiplies output alpha by <c>(1 - x)</c>. For the rain
|
||||
/// surface 0x080000C5 this is 0.5 → opacity 0.5 → rain streaks
|
||||
/// contribute at half intensity under the additive blend, matching
|
||||
/// retail's <c>curr_alpha</c> derivation in
|
||||
/// <c>D3DPolyRender::SetSurface</c> at <c>0x59c767</c>.
|
||||
/// </summary>
|
||||
public float SurfTranslucency;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue