acdream/src/AcDream.App/Rendering/Shaders/sky.vert
Erik ec1bbb4f43 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>
2026-04-28 22:47:11 +02:00

116 lines
5.1 KiB
GLSL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#version 430 core
// Sky mesh vertex shader — retail-verbatim D3D fixed-function lighting
// ported to per-vertex GLSL. Evidence trail:
//
// docs/research/2026-04-23-sky-material-state.md
// §Q2 — retail FUN_0059da60 writes D3DMATERIAL9 per-mesh:
// Material.Emissive.rgb = (Surface.Luminosity, Lum, Lum, 1)
// Material.Ambient/Diffuse from texture-modulate defaults
// §Q4 — D3DRS_LIGHTING is ON for sky meshes
// §Q6 — fragment formula:
// lit = Emissive
// + material.Ambient × light.Ambient
// + material.Diffuse × light.Diffuse × max(dot(N, -sun), 0)
//
// Our `uAmbientColor` = retail's light.Ambient (AmbColor × AmbBright,
// pre-multiplied by SkyDescLoader). `uSunColor` = retail's light.Diffuse
// (DirColor × DirBright). `uSunDir` is a unit vector FROM surface TO
// sun (so `dot(N, uSunDir)` is the diffuse intensity directly; no
// extra negation needed — see SkyStateProvider.SunDirectionFromKeyframe).
// `uEmissive` is Surface.Luminosity for this submesh.
//
// Phase 2 (2026-04-23) tried the same formula and produced a visible
// east/west "blue-green-yellow sweep" — in hindsight that was CORRECT
// retail behaviour but paired with a wrong DayGroup pick ("Sunny" with
// sharp warm sun when retail rolled "Cloudy" with diffuse overcast).
// After Phase 3g fixed the LCG multiplier so acdream + retail agree on
// the DayGroup, the same formula should now match retail visually.
//
// NOTE: no clamp at the vertex — retail's D3D fixed-function lighting
// can produce lit values > 1.0 and the final clamp happens at the
// framebuffer write. Doing that same "let it overbright" here keeps
// the dome's emissive=1 saturation path intact.
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec2 aTex;
uniform mat4 uModel;
uniform mat4 uSkyView;
uniform mat4 uSkyProjection;
uniform vec2 uUvScroll;
// Per-frame lighting (from SkyKeyframe):
uniform vec3 uAmbientColor; // AmbColor × AmbBright (retail light.Ambient)
uniform vec3 uSunColor; // DirColor × DirBright (retail light.Diffuse)
uniform vec3 uSunDir; // unit vector FROM surface TO sun
// Per-submesh (from Surface.Luminosity float):
uniform float uEmissive;
uniform float uDiffuseFactor;
// Shared SceneLighting UBO — we need uFogParams.xy (fog start/end) to
// compute the vertex fog factor. Must match sky.frag's declaration.
struct Light {
vec4 posAndKind;
vec4 dirAndRange;
vec4 colorAndIntensity;
vec4 coneAngleEtc;
};
layout(std140, binding = 1) uniform SceneLighting {
Light uLights[8];
vec4 uCellAmbient;
vec4 uFogParams; // x=fogStart, y=fogEnd, z=flash, w=fogMode
vec4 uFogColor;
vec4 uCameraAndTime;
};
out vec2 vTex;
out vec3 vTint;
out float vFogFactor; // 1 = no fog (close), 0 = full fog (far)
void main() {
vTex = aTex + uUvScroll;
gl_Position = uSkyProjection * uSkyView * uModel * vec4(aPos, 1.0);
// uModel for sky is pure rotation (Z then Y) — orthonormal, so
// mat3(uModel) transforms normals correctly without inverse-transpose.
vec3 worldNormal = normalize(mat3(uModel) * aNormal);
// Retail per-vertex fixed-function lighting (AMBIENT=0 globally,
// so the global ambient term drops; only light.Ambient contributes).
// Clamp to [0,1] at the vertex — retail's D3DRS_COLORCLAMP defaults
// to clamping lit vertex colours to 1.0 BEFORE texture modulate.
// Without this, a dome vertex (uEmissive=1) picks up ambient+diff
// on top of already-saturated emissive, producing > 1.5 lit values
// that our framebuffer cap (1.2) lets through as 20% overbright
// vs retail's 1.0-clamped reference. User-observed 2026-04-23.
float diff = max(dot(worldNormal, uSunDir), 0.0);
vec3 lit = vec3(uEmissive) // material.Emissive
+ uAmbientColor // material.Ambient(1) × light.Ambient
+ (uSunColor * uDiffuseFactor) * diff;
vTint = clamp(lit, 0.0, 1.0);
// Retail vertex-fog in 3D-range mode (FOGVERTEXMODE=LINEAR,
// RANGEFOGENABLE=1, FOGTABLEMODE=NONE per device init — never
// toggled per frame). Distance = `|worldPos - cameraPos|`. Since
// our sky view matrix has translation zeroed (sky is camera-
// centered), the post-uModel position IS the camera-relative
// world-space vector, so its length is the 3D range distance.
// See docs/research/2026-04-23-sky-fog.md.
//
// Formula: fogFactor = clamp((fogEnd - dist) / (fogEnd - fogStart), 0, 1)
// 1.0 → no fog contribution (scene color wins)
// 0.0 → full fog color (sky color fades to fog)
//
// Sky meshes have intrinsic radii in the thousands of meters (dome
// / stars / moon are authored at large distances in the dat); at
// typical keyframe FOGEND=2400m, the dome saturates to fogColor at
// its horizon band. THAT is how retail colors the horizon at dusk.
vec4 worldPos = uModel * vec4(aPos, 1.0);
float dist = length(worldPos.xyz);
float fogStart = uFogParams.x;
float fogEnd = uFogParams.y;
float span = max(fogEnd - fogStart, 1e-3);
vFogFactor = clamp((fogEnd - dist) / span, 0.0, 1.0);
}