acdream/src/AcDream.Core/Meshing/GfxObjMesh.cs
Erik 4678b3ee6b fix(sky): apply per-Surface Translucency + Luminosity for retail-faithful weather
Two independent brightness bugs were compounding to make rain ~6.7×
too bright at the cylinder rim, and clouds full-bright instead of
time-of-day-tinted:

**Fix 1 — Surface.Translucency was never plumbed to the shader.**

Retail's D3DPolyRender::SetSurface at 0x59c767: when the Surface's
Translucent (0x10) bit is set, its translucency float drives per-vertex
alpha (curr_alpha = ftol(0.5 × 255) = 127). ACViewer
(TextureCache.cs:142) and WorldBuilder (ObjectMeshManager.cs:1115) both
encode the same as `opacity = (1 - x)`. acdream read only Surface.Type
and Surface.Luminosity in GfxObjMesh.Build() — Surface.Translucency
(the float) was never read, never stored, never reached the shader.
For the rain Surface 0x080000C5 (Translucency=0.5) this meant rain
streaks were at full alpha=1.0 instead of 0.5 — 2× brighter than retail
under the (SrcAlpha, One) blend.

Plumbed end-to-end:
  GfxObjSubMesh.SurfTranslucency (init float, default 0)
  GfxObjMesh.Build() reads surface.Translucency next to .Luminosity
  SubMeshGpu.SurfTranslucency carries it to draw time
  SkyRenderer.RenderPass writes uniform `uSurfTranslucency`
  sky.frag final alpha: a = sampled.a × (1 - uTransparency) ×
                            (1 - uSurfTranslucency)

Bonus reach: cloud surface 0x08000023 has Translucency=0.25 → clouds
also dimmed by 25%, more retail-faithful overall.

**Fix 2 — Emissive default was 1.0 instead of the surface's actual Luminosity.**

The sky shader's `effEmissive = (luminosity > 0) ? luminosity : sub.SurfLuminosity`
fallback never fired because the local `luminosity` defaulted to 1f (always
> 0). Every sky mesh got effEmissive=1.0, saturating vTint to white before
the alpha blend. The comment claimed the fallback was active; the code
disagreed.

Empirical sky-surface LUMINOUS audit (RainMeshProbe a6e7108) found that
NO Dereth sky surface carries the SurfaceType.Luminous flag (0x40) —
the previous code comment that did was wrong. The differentiator is
purely the Surface.Luminosity FLOAT:
  dome/sun/moon: Lum=1.0 → vTint saturates → texture passthrough
  stars/clouds:  Lum=0.0 → vTint = ambient + sun·N·L → time-of-day tint
  rain:          Lum=0.1484 → faint emissive baseline + lit additions

Refactored:
  replaceLuminosity = NaN sentinel for "no replace override"
  rep.Luminosity > 0  → set replaceLuminosity to override value
  rep.MaxBright  > 0  → cap replaceLuminosity at MaxBright
  effEmissive = NaN ? sub.SurfLuminosity : replaceLuminosity

Dead uniform `uLuminosity` removed from sky.frag and SkyRenderer SetFloat
call — the redundant multiply was already commented-out earlier this
year (would have double-dimmed clouds), and the uniform value was unused
in the fragment.

Visual verification (Holtburg, live ACE, Rainy DG forced and natural
LCG-picked): rain rim is no longer visible; cloud direction matches
retail when the same DayGroup is active; sky lighting transitions through
day cycle with appropriate time-of-day tint on stars/clouds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:04:55 +02:00

253 lines
12 KiB
C#

using System.Numerics;
using AcDream.Core.Terrain;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
namespace AcDream.Core.Meshing;
public static class GfxObjMesh
{
/// <summary>
/// Walk a GfxObj's polygons and produce one <see cref="GfxObjSubMesh"/>
/// per referenced Surface, emitting positive-side and negative-side
/// triangles separately when the polygon specifies both.
/// </summary>
/// <param name="gfxObj">The GfxObj to build sub-meshes from.</param>
/// <param name="dats">
/// Optional dat collection used to read Surface.Type flags and set
/// <see cref="GfxObjSubMesh.Translucency"/>. When null (e.g. offline tests)
/// all sub-meshes default to <see cref="TranslucencyKind.Opaque"/>.
/// </param>
/// <remarks>
/// <para>
/// Ported from
/// <c>references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs</c>
/// (BuildPolygonIndices + the pos/neg emission loop around line 955).
/// The rule for emitting a polygon side:
/// </para>
/// <list type="bullet">
/// <item><b>Pos side:</b> emit whenever <c>!Stippling.NoPos</c> and
/// <c>PosSurface</c> is a valid index.</item>
/// <item><b>Neg side:</b> emit when
/// <c>Stippling.Negative</c>, <c>Stippling.Both</c>, or
/// <c>(!Stippling.NoNeg &amp;&amp; SidesType == CullMode.Clockwise)</c>.
/// The last condition is AC's non-obvious convention for "this
/// polygon has a back face even though nothing in Stippling
/// declares it" — you cannot drop it without punching holes
/// through closed meshes like the lifestone and any other
/// weenie that relies on double-sided polygons.</item>
/// </list>
/// <para>
/// Neg-side triangles get the reversed winding and a negated vertex
/// normal so lighting stays correct if we ever enable face culling;
/// acdream currently renders with culling disabled, but we still emit
/// reversed indices to keep the semantics right.
/// </para>
/// <para>
/// The dedup cache is keyed by <c>(posIdx, uvIdx, isNeg)</c> because
/// the same vertex position on the pos and neg sides needs different
/// normals (and potentially different UVs via <c>NegUVIndices</c>).
/// </para>
/// </remarks>
public static IReadOnlyList<GfxObjSubMesh> Build(GfxObj gfxObj, DatCollection? dats = null)
{
// One bucket per (surface-index, isNeg) pair. Negative-side triangles
// always land in a different bucket than their positive counterparts
// because their normals and winding differ; the renderer doesn't care
// about the distinction once sub-meshes are emitted, but the build
// loop has to keep them separate to produce correct vertex data.
var perBucket = new Dictionary<(int surfaceIdx, bool isNeg),
(List<Vertex> Vertices, List<uint> Indices,
Dictionary<(int pos, int uv, bool neg), uint> Dedupe)>();
foreach (var kvp in gfxObj.Polygons)
{
var poly = kvp.Value;
if (poly.VertexIds.Count < 3)
continue; // degenerate — can't form a triangle
// --- Positive side ---
bool hasPos = !poly.Stippling.HasFlag(StipplingType.NoPos);
if (hasPos)
EmitSide(poly, poly.PosSurface, isNeg: false);
// --- Negative side ---
// Three ways AC flags a polygon as double-sided:
// 1. Stippling.Negative or Stippling.Both — explicit.
// 2. Stippling.NoNeg is NOT set AND SidesType == Clockwise —
// AC's "Clockwise CullMode means there are NegUVIndices
// on the wire" convention. See
// DatReaderWriter/.../Generated/Types/Polygon.generated.cs
// — NegUVIndices are only read when SidesType == Clockwise,
// and WorldBuilder uses the same rule to decide whether to
// emit the neg side at build time.
bool hasNeg =
poly.Stippling.HasFlag(StipplingType.Negative) ||
poly.Stippling.HasFlag(StipplingType.Both) ||
(!poly.Stippling.HasFlag(StipplingType.NoNeg) && poly.SidesType == CullMode.Clockwise);
if (hasNeg)
EmitSide(poly, poly.NegSurface, isNeg: true);
void EmitSide(DatReaderWriter.Types.Polygon p, short surfaceIdx, bool isNeg)
{
if (surfaceIdx < 0 || surfaceIdx >= gfxObj.Surfaces.Count)
return;
var bucketKey = ((int)surfaceIdx, isNeg);
if (!perBucket.TryGetValue(bucketKey, out var bucket))
{
bucket = (new List<Vertex>(), new List<uint>(),
new Dictionary<(int, int, bool), uint>());
perBucket[bucketKey] = bucket;
}
// Collect one output index per polygon corner. If we fail to
// resolve a vertex we abort the whole polygon rather than
// emitting a degenerate triangle (matches the behavior of
// the previous builder).
var polyOut = new List<uint>(p.VertexIds.Count);
bool skipPoly = false;
for (int i = 0; i < p.VertexIds.Count; i++)
{
int posIdx = p.VertexIds[i];
// UV index selection: neg side uses NegUVIndices when
// present; otherwise fall back to PosUVIndices; otherwise
// zero. Matches WorldBuilder/ObjectMeshManager.cs:1521-1524.
int uvIdx = 0;
if (isNeg && p.NegUVIndices.Count > 0 && i < p.NegUVIndices.Count)
uvIdx = p.NegUVIndices[i];
else if (!isNeg && i < p.PosUVIndices.Count)
uvIdx = p.PosUVIndices[i];
else if (i < p.PosUVIndices.Count)
uvIdx = p.PosUVIndices[i]; // neg side with no NegUVIndices — borrow pos
if (!gfxObj.VertexArray.Vertices.TryGetValue((ushort)posIdx, out var sw))
{
skipPoly = true;
break;
}
var texcoord = uvIdx >= 0 && uvIdx < sw.UVs.Count
? new Vector2(sw.UVs[uvIdx].U, sw.UVs[uvIdx].V)
: Vector2.Zero;
// Negate the vertex normal for the neg side so lighting
// stays correct if we ever enable face culling. With
// culling disabled the shader still samples this normal
// for the diffuse term so getting it right matters
// regardless of backface state.
var normal = System.Numerics.Vector3.Normalize(isNeg ? -sw.Normal : sw.Normal);
var key = (posIdx, uvIdx, isNeg);
if (!bucket.Dedupe.TryGetValue(key, out var outIdx))
{
outIdx = (uint)bucket.Vertices.Count;
bucket.Vertices.Add(new Vertex(sw.Origin, normal, texcoord, TerrainLayer: 0));
bucket.Dedupe[key] = outIdx;
}
polyOut.Add(outIdx);
}
if (skipPoly || polyOut.Count < 3)
return;
// Fan triangulation. Pos side keeps the original
// (0, i, i+1) winding the earlier builder used so existing
// tests and render behavior are preserved. Neg side emits
// the opposite winding so the two faces point away from
// each other — matches WorldBuilder/ObjectMeshManager.cs:
// 1564-1577 once you account for the reversed pos order.
if (isNeg)
{
for (int i = 1; i < polyOut.Count - 1; i++)
{
bucket.Indices.Add(polyOut[i + 1]);
bucket.Indices.Add(polyOut[i]);
bucket.Indices.Add(polyOut[0]);
}
}
else
{
for (int i = 1; i < polyOut.Count - 1; i++)
{
bucket.Indices.Add(polyOut[0]);
bucket.Indices.Add(polyOut[i]);
bucket.Indices.Add(polyOut[i + 1]);
}
}
}
}
// Emit one sub-mesh per (surface, side) bucket. The sub-mesh API
// doesn't care whether a surface came from the pos or neg side —
// both go through the same texture cache path.
var result = new List<GfxObjSubMesh>(perBucket.Count);
foreach (var kvp in perBucket)
{
var (surfaceIdx, _) = kvp.Key;
var surfaceId = (uint)gfxObj.Surfaces[surfaceIdx];
// Resolve Surface.Type flags when a DatCollection is available
// so the renderer can split the draw into opaque and translucent
// passes. Also capture Surface.Luminosity (self-illumination
// coefficient — the FLOAT field, NOT the SurfaceType.Luminous
// flag bit). This is the retail signal used to make the sky
// dome / sun / moon texture-passthrough while clouds pick up
// the time-of-day ambient tint (see
// docs/research/2026-04-23-sky-retail-verbatim.md §6).
var translucency = TranslucencyKind.Opaque;
var luminosity = 0f;
var surfTranslucency = 0f;
if (dats is not null)
{
var surface = dats.Get<Surface>(surfaceId);
if (surface is not null)
{
translucency = TranslucencyKindExtensions.FromSurfaceType(surface.Type);
luminosity = surface.Luminosity;
// Retail D3DPolyRender::SetSurface at 0x59c767: when the
// Translucent (0x10) flag is set, the surface's
// Translucency float drives per-vertex alpha. Both
// ACViewer and WorldBuilder apply opacity = (1 - x).
// For the rain Surface 0x080000C5 this is 0.5. Carrying
// the float verbatim and converting to opacity in the
// shader keeps non-Translucent surfaces (Translucency=0)
// identical to the previous behavior.
surfTranslucency = surface.Translucency;
}
}
// Authored UV range determines the wrap-mode choice in the
// sky pass. A mesh whose UVs are strictly in [0,1] (e.g. the
// outer dome 0x010015EE) wants CLAMP_TO_EDGE to avoid
// bilinear-filter bleed at the wall-seam edges; a mesh whose
// UVs deliberately tile (e.g. 0x010015EF, ~0.4..4.6) wants
// REPEAT so the texture tiles across the geometry. We make
// the call data-driven here rather than guessing from
// TexVelocity at draw time. See
// docs/research/2026-04-26-sky-investigation-handoff.md (Bug B).
bool needsUvRepeat = false;
foreach (var v in kvp.Value.Vertices)
{
if (v.TexCoord.X < 0f || v.TexCoord.X > 1f
|| v.TexCoord.Y < 0f || v.TexCoord.Y > 1f)
{ needsUvRepeat = true; break; }
}
result.Add(new GfxObjSubMesh(
SurfaceId: surfaceId,
Vertices: kvp.Value.Vertices.ToArray(),
Indices: kvp.Value.Indices.ToArray())
{
Translucency = translucency,
Luminosity = luminosity,
NeedsUvRepeat = needsUvRepeat,
SurfTranslucency = surfTranslucency,
});
}
return result;
}
}