feat(vfx): Phase C.1 — PES particle renderer + post-review fixes
Ports retail's ParticleEmitterInfo / Particle::Init / Particle::Update
(0x005170d0..0x0051d400) and PhysicsScript runtime to a C# data-layer
plus a Silk.NET billboard renderer. Sky-PES path is debug-only behind
ACDREAM_ENABLE_SKY_PES because named-retail decomp confirms GameSky
copies SkyObject.pes_id but never reads it (CreateDeletePhysicsObjects
0x005073c0, MakeObject 0x00506ee0, UseTime 0x005075b0).
Post-review fixes folded into this commit:
H1: AttachLocal (is_parent_local=1) follows live parent each frame.
ParticleSystem.UpdateEmitterAnchor + ParticleHookSink.UpdateEntityAnchor
let the owning subsystem refresh AnchorPos every tick — matches
ParticleEmitter::UpdateParticles 0x0051d2d4 which re-reads the live
parent frame when is_parent_local != 0. Drops the renderer-side
cameraOffset hack that only worked when the parent was the camera.
H3: Strip the long stale comment in GfxObjMesh.cs that contradicted the
retail-faithful (1 - translucency) opacity formula. The code was
right; the comment was a leftover from an earlier hypothesis and
would have invited a wrong "fix".
M1: SkyRenderer tracks textures whose wrap mode it set to ClampToEdge
and restores them to Repeat at end-of-pass, so non-sky renderers
that share the GL handle can't silently inherit clamped wrap state.
M2: Post-scene Z-offset (-120m) only fires when the SkyObject is
weather-flagged AND bit 0x08 is clear, matching retail
GameSky::UpdatePosition 0x00506dd0. The old code applied it to
every post-scene object — a no-op today (every Dereth post-scene
entry happens to be weather-flagged) but a future post-scene-only
sun rim would have been pushed below the camera.
M4: ParticleSystem.EmitterDied event lets ParticleHookSink prune dead
handles from the per-entity tracking dictionaries, fixing a slow
leak where naturally-expired emitters' handles stayed in the
ConcurrentBag forever during long sessions.
M5: SkyPesEntityId moves the post-scene flag bit to 0x08000000 so it
can't ever overlap the object-index range. Synthetic IDs stay in
the reserved 0xFxxxxxxx space.
New tests (ParticleSystemTests + ParticleHookSinkTests):
- UpdateEmitterAnchor_AttachLocal_ParticlePositionFollowsLiveAnchor
- UpdateEmitterAnchor_AttachLocalCleared_ParticleFrozenAtSpawnOrigin
- EmitterDied_FiresOncePerHandle_AfterAllParticlesExpire
- Birthrate_PerSec_EmitsOnePerTickWhenIntervalElapsed (retail-faithful
single-emit-per-frame behavior)
- UpdateEntityAnchor_WithAttachLocal_MovesParticleToLiveAnchor
- EmitterDied_PrunesPerEntityHandleTracking
dotnet build green, dotnet test green: 695 / 393 / 243 = 1331 passed
(up from 1325).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1f82b7604e
commit
ec1bbb4f43
28 changed files with 2444 additions and 780 deletions
|
|
@ -106,8 +106,10 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
Vector3 cameraWorldPos,
|
||||
float dayFraction,
|
||||
DayGroupData? group,
|
||||
SkyKeyframe keyframe)
|
||||
=> RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, postScenePass: false);
|
||||
SkyKeyframe keyframe,
|
||||
bool environOverrideActive = false)
|
||||
=> RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe,
|
||||
postScenePass: false, environOverrideActive: environOverrideActive);
|
||||
|
||||
/// <summary>
|
||||
/// Draw the POST-SCENE sky objects (the foreground rain mesh
|
||||
|
|
@ -134,8 +136,10 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
Vector3 cameraWorldPos,
|
||||
float dayFraction,
|
||||
DayGroupData? group,
|
||||
SkyKeyframe keyframe)
|
||||
=> RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, postScenePass: true);
|
||||
SkyKeyframe keyframe,
|
||||
bool environOverrideActive = false)
|
||||
=> RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe,
|
||||
postScenePass: true, environOverrideActive: environOverrideActive);
|
||||
|
||||
/// <summary>
|
||||
/// Shared pass for <see cref="RenderSky"/> and <see cref="RenderWeather"/>.
|
||||
|
|
@ -151,7 +155,8 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
float dayFraction,
|
||||
DayGroupData? group,
|
||||
SkyKeyframe keyframe,
|
||||
bool postScenePass)
|
||||
bool postScenePass,
|
||||
bool environOverrideActive)
|
||||
{
|
||||
if (group is null || group.SkyObjects.Count == 0) return;
|
||||
|
||||
|
|
@ -209,6 +214,12 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
|
||||
float secondsSinceStart = (float)(DateTime.UtcNow - _startedAt).TotalSeconds;
|
||||
|
||||
// M1: track texture handles whose wrap mode we set to ClampToEdge
|
||||
// so we can restore them to Repeat (TextureCache's default upload
|
||||
// state) at end-of-pass. Without this, any subsequent renderer
|
||||
// sharing the texture handle would silently inherit ClampToEdge.
|
||||
var clampedTextures = new HashSet<uint>();
|
||||
|
||||
for (int i = 0; i < group.SkyObjects.Count; i++)
|
||||
{
|
||||
var obj = group.SkyObjects[i];
|
||||
|
|
@ -227,6 +238,11 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
// foreground rain — double-thick rain not matching retail.
|
||||
if (obj.IsPostScene != postScenePass) continue;
|
||||
if (!obj.IsVisible(dayFraction)) continue;
|
||||
// Retail GameSky::Draw (0x00506ff0) skips Properties bit 0x02
|
||||
// objects while an AdminEnvirons fog override is active. Normal
|
||||
// DayGroup fog/tint still draws them.
|
||||
if (environOverrideActive && (obj.Properties & 0x02u) != 0u)
|
||||
continue;
|
||||
|
||||
// Apply per-keyframe replace overrides.
|
||||
uint gfxObjId = obj.GfxObjId;
|
||||
|
|
@ -243,20 +259,18 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
// NO Dereth sky surface carries the SurfaceType.Luminous flag
|
||||
// bit (0x40) — the differentiator is purely the float field.
|
||||
float replaceLuminosity = float.NaN;
|
||||
float replaceDiffuse = 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) 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).
|
||||
// Retail GameSky::UseTime routes max_bright through
|
||||
// CPhysicsObj::SetDiffusion, so it replaces material diffuse,
|
||||
// not emissive/luminosity.
|
||||
if (rep.MaxBright > 0f)
|
||||
replaceLuminosity = float.IsNaN(replaceLuminosity)
|
||||
? rep.MaxBright
|
||||
: MathF.Min(replaceLuminosity, rep.MaxBright);
|
||||
replaceDiffuse = rep.MaxBright;
|
||||
}
|
||||
if (gfxObjId == 0) continue;
|
||||
|
||||
|
|
@ -277,18 +291,24 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
// 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)
|
||||
// Gate: bit 0x04 (weather) set AND bit 0x08 unset. NOT every
|
||||
// post-scene SkyObject — bit 0x01 (post-scene) is independent
|
||||
// of bit 0x04 (weather). Today's Dereth ships every post-scene
|
||||
// entry as also weather-flagged so the previous unconditional
|
||||
// offset was a no-op divergence, but a future DayGroup with a
|
||||
// post-scene-but-not-weather entry (e.g. a foreground sun rim)
|
||||
// would have been pushed 120m below the camera and rendered as
|
||||
// floor lint.
|
||||
//
|
||||
// Without the offset on the rain cylinder GfxObjs
|
||||
// 0x01004C42/0x01004C44 (local Z range 0.11..814.90) the
|
||||
// cylinder bottom sits at z=0.11 ABOVE the camera (skyView
|
||||
// translation is zeroed so model-origin == camera); looking
|
||||
// horizontally shows nothing. With -120m the cylinder spans z
|
||||
// = (camera-119.89)..(camera+694.90) — camera is inside,
|
||||
// looking in any direction shows surrounding walls — the
|
||||
// volumetric foreground-rain look retail has.
|
||||
if (postScenePass && obj.IsWeather && (obj.Properties & 0x08u) == 0u)
|
||||
model = model * Matrix4x4.CreateTranslation(0f, 0f, -120f);
|
||||
|
||||
_shader.SetMatrix4("uModel", model);
|
||||
|
|
@ -343,20 +363,17 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
float effEmissive = float.IsNaN(replaceLuminosity)
|
||||
? sub.SurfLuminosity
|
||||
: replaceLuminosity;
|
||||
float effDiffuse = float.IsNaN(replaceDiffuse)
|
||||
? sub.SurfDiffuse
|
||||
: replaceDiffuse;
|
||||
_shader.SetFloat("uEmissive", effEmissive);
|
||||
_shader.SetFloat("uDiffuseFactor", effDiffuse);
|
||||
|
||||
// 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);
|
||||
// Material alpha is final opacity: 1 - Surface.Translucency
|
||||
// for Translucent surfaces, 1 for non-Translucent surfaces.
|
||||
// The CPU computes it once so the shader just multiplies it
|
||||
// with texture alpha and keyframe transparency.
|
||||
_shader.SetFloat("uSurfOpacity", sub.SurfOpacity);
|
||||
|
||||
// Retail D3DPolyRender::SetSurface at 0x59c882 calls
|
||||
// SetFFFogAlphaDisabled(1) when the Additive flag (0x10000)
|
||||
|
|
@ -364,9 +381,12 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
// 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);
|
||||
// Non-additive sky meshes (the dome/background layers)
|
||||
// still mix toward keyframe fog with the floor mitigation
|
||||
// in sky.frag. That restores the broad green/purple Rainy
|
||||
// DayGroup tint behind the cloud sheet while raw-additive
|
||||
// 0x08000023 remains unfogged and keeps the pink detail.
|
||||
_shader.SetFloat("uApplyFog", sub.DisableFog ? 0f : 1f);
|
||||
|
||||
uint tex = _textures.GetOrUpload(sub.SurfaceId);
|
||||
_gl.ActiveTexture(TextureUnit.Texture0);
|
||||
|
|
@ -396,11 +416,25 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
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);
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, wrapMode);
|
||||
if (!needsRepeat)
|
||||
{
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS,
|
||||
(int)TextureWrapMode.ClampToEdge);
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT,
|
||||
(int)TextureWrapMode.ClampToEdge);
|
||||
clampedTextures.Add(tex);
|
||||
}
|
||||
// No else branch: TextureCache uploads with Repeat, so a
|
||||
// texture whose wrap was clamped earlier this pass and is
|
||||
// re-bound now still needs to be told to Repeat.
|
||||
else if (clampedTextures.Contains(tex))
|
||||
{
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS,
|
||||
(int)TextureWrapMode.Repeat);
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT,
|
||||
(int)TextureWrapMode.Repeat);
|
||||
clampedTextures.Remove(tex);
|
||||
}
|
||||
|
||||
_gl.BindVertexArray(sub.Vao);
|
||||
_gl.DrawElements(PrimitiveType.Triangles,
|
||||
|
|
@ -410,6 +444,18 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
// M1: restore wrap mode on every texture this pass clamped, so
|
||||
// the rest of the pipeline sees TextureCache's default Repeat
|
||||
// state regardless of which sky-mesh order we drew.
|
||||
foreach (var tex in clampedTextures)
|
||||
{
|
||||
_gl.BindTexture(TextureTarget.Texture2D, tex);
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS,
|
||||
(int)TextureWrapMode.Repeat);
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT,
|
||||
(int)TextureWrapMode.Repeat);
|
||||
}
|
||||
|
||||
// Restore GL state expected by the rest of the pipeline.
|
||||
_gl.Disable(EnableCap.Blend);
|
||||
_gl.DepthMask(true);
|
||||
|
|
@ -639,7 +685,7 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
Console.WriteLine(
|
||||
$"[sky-dump] Surface[{i}] 0x{surfaceId:X8} Type=0x{rawType:X8} ({names}) " +
|
||||
$"OrigTexture=0x{origTex:X8} Translucency={trans} " +
|
||||
$"SurfLuminosity={surface.Luminosity:F4} SurfTranslucency={surface.Translucency:F4}");
|
||||
$"SurfLuminosity={surface.Luminosity:F4} SurfaceTranslucency={surface.Translucency:F4}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -692,8 +738,10 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
SurfaceId = sm.SurfaceId,
|
||||
IsAdditive = isAdditive,
|
||||
SurfLuminosity = sm.Luminosity,
|
||||
SurfDiffuse = sm.Diffuse,
|
||||
NeedsUvRepeat = sm.NeedsUvRepeat,
|
||||
SurfTranslucency = sm.SurfTranslucency,
|
||||
SurfOpacity = sm.SurfOpacity,
|
||||
DisableFog = sm.DisableFog,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -733,6 +781,7 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
/// <c>docs/research/2026-04-23-sky-retail-verbatim.md</c> §6.
|
||||
/// </summary>
|
||||
public float SurfLuminosity;
|
||||
public float SurfDiffuse;
|
||||
/// <summary>
|
||||
/// True when the source mesh's authored UVs exceed [0,1] (e.g.
|
||||
/// the inner sky/star layer 0x010015EF and the cloud meshes —
|
||||
|
|
@ -744,17 +793,11 @@ public sealed unsafe class SkyRenderer : IDisposable
|
|||
/// </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> and used
|
||||
/// DIRECTLY as opacity (NOT <c>1 - x</c>). Retail's
|
||||
/// <c>D3DPolyRender::SetSurface</c> at <c>0x59c7a6</c>
|
||||
/// (decomp lines 425255-425260) computes
|
||||
/// <c>curr_alpha = _ftol2(translucency × 255)</c> and writes that
|
||||
/// as vertex.color.alpha — i.e. translucency is opacity directly.
|
||||
/// For non-Translucent surfaces the GfxObjMesh.Build() path keeps
|
||||
/// this at 1.0 so they stay fully opaque.
|
||||
/// Final surface opacity from <see cref="GfxObjSubMesh.SurfOpacity"/>.
|
||||
/// Translucent surfaces use <c>1 - Surface.Translucency</c>; other
|
||||
/// surfaces stay at 1.0.
|
||||
/// </summary>
|
||||
public float SurfTranslucency;
|
||||
public float SurfOpacity;
|
||||
public bool DisableFog;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue