Three retail-faithful sky/weather composite fixes (one cohesive commit because they touch the same per-Surface flag plumbing path). 1. Surface.Translucency is OPACITY, not (1 - opacity). Retail D3DPolyRender::SetSurface at 0x59c7a6 (decomp 425255-425260) computes `curr_alpha = _ftol2(translucency × 255)` and writes that directly as vertex.color.alpha. ACViewer (TextureCache.cs:142) and WorldBuilder (ObjectMeshManager.cs:1115) both use `1 - translucency` and are wrong by the same misread. Cloud surface 0x08000023 has Translucency=0.25; under the old (1-x) formula opacity was 0.75, making clouds 3× too bright vs retail. Flipped to use translucency directly. Gated on the Translucent flag (0x10) so non-Translucent surfaces (which carry Translucency=0 in the dat) keep opacity 1.0 instead of going invisible. 2. Sky fog re-enabled with a "fog floor" mitigation. Disabled 2026-04-24 because Dereth sky meshes are authored at radii 1050-1820m while storm-keyframe FogEnd is ~400m, which would saturate the entire dome to flat fogColor and destroy stars/moon/dome texture. Retail visibly DOES fog its sky, mechanism still un-pinned. Workaround: clamp `vFogFactor` to a minimum of SKY_FOG_FLOOR=0.2 so the dome shows AT LEAST 20% raw texture even at extreme distances. Tuned via dual- client visual comparison; preserves stars/moon while letting the horizon haze visibly in low-FogEnd keyframes. 3. Additive sky surfaces skip fog entirely. Retail D3DPolyRender::SetSurface at 0x59c882 calls SetFFFogAlphaDisabled(1) when the Additive flag (0x10000) is set — sun, moon, stars, additive cloud sheets render unfogged. Without this gate the sun dimmed to fog color at horizon dusk/dawn instead of staying bright. Plumbed via new `uApplyFog` shader uniform driven by the existing SubMeshGpu.IsAdditive boolean (already set from TranslucencyKind.Additive at upload time). User visually verified all three vs retail screenshots in Holtburg. Tests: 1223 pass.
265 lines
13 KiB
C#
265 lines
13 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 && 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;
|
||
// SurfTranslucency = the OPACITY multiplier the shader applies
|
||
// to fragment alpha. 1.0 = fully opaque (default, non-Translucent
|
||
// surfaces). For Translucent-flag surfaces, retail's
|
||
// D3DPolyRender::SetSurface at 0x59c7a6 (decomp lines 425255-
|
||
// 425260) computes curr_alpha = _ftol2(translucency × 255) and
|
||
// feeds that as vertex.color.alpha — so the dat's Translucency
|
||
// float is the OPACITY directly (NOT inverted). For rain
|
||
// (translucency=0.5) opacity is 0.5; for cloud surface
|
||
// 0x08000023 (translucency=0.25) opacity is 0.25 — that's why
|
||
// retail's clouds are dim and acdream's were 3× too bright
|
||
// before this fix (we used 1-translucency, inverting the
|
||
// semantic). ACViewer's TextureCache.cs:142 and WorldBuilder's
|
||
// ObjectMeshManager.cs:1115 also use 1-translucency and are
|
||
// both wrong by the same misread.
|
||
var surfTranslucency = 1.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;
|
||
// Apply the dat's Translucency value as opacity ONLY
|
||
// when the Translucent flag (0x10) is set on the
|
||
// Surface. Without this gate, surfaces with
|
||
// Translucency=0 (non-Translucent default) would
|
||
// render fully transparent.
|
||
if (((uint)surface.Type & (uint)DatReaderWriter.Enums.SurfaceType.Translucent) != 0)
|
||
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;
|
||
}
|
||
}
|