using System.Numerics; using AcDream.Core.Terrain; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; namespace AcDream.Core.Meshing; public static class GfxObjMesh { /// /// Walk a GfxObj's polygons and produce one /// per referenced Surface, emitting positive-side and negative-side /// triangles separately when the polygon specifies both. /// /// The GfxObj to build sub-meshes from. /// /// Optional dat collection used to read Surface.Type flags and set /// . When null (e.g. offline tests) /// all sub-meshes default to . /// /// /// /// Ported from /// references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs /// (BuildPolygonIndices + the pos/neg emission loop around line 955). /// The rule for emitting a polygon side: /// /// /// Pos side: emit whenever !Stippling.NoPos and /// PosSurface is a valid index. /// Neg side: emit when /// Stippling.Negative, Stippling.Both, or /// (!Stippling.NoNeg && SidesType == CullMode.Clockwise). /// 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. /// /// /// 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. /// /// /// The dedup cache is keyed by (posIdx, uvIdx, isNeg) because /// the same vertex position on the pos and neg sides needs different /// normals (and potentially different UVs via NegUVIndices). /// /// public static IReadOnlyList 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 Vertices, List 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(), new List(), 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(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(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(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; } }