feat(content): S1 exact CellStruct surface-index construction, recipe 8
Campaign OVERHAUL S1 chunk A. Retail's D3DPolyRender::ConstructMesh @0x0059DFA0 is ported as one pure Core descriptor plus the Content extraction that consumes it: - side candidates come only from sides_type (0/1/2); NoPos/NoNeg mean UV-array absence only and never suppress a side (CPolygon::UnPack @0x00538650); - ST_DOUBLE's second copy is reversed with a negative normal; ST_BOTH's negative side has a negative normal and forward fan order (reverse is on the copy ordinal, not the side ordinal); - an absent UV-index array is UV index 0 (ConstructMesh @0x0059E691 xor ebx,ebx, arbitrated on the PDB-paired binary); copyVert @0x0059C080 zeroes coordinates only for a negative or out-of-range index or a vertex without UVs, never by clamping to slot 0; - the subset owner is the source surface-array index, emitted in ascending slot order with retail's per-slot mask (2 > 8 > 4 precedence, positive surface OR on signed stippling > 0); - built-EnvCell admission is (Surface.Type & (BASE1_IMAGE|BASE1_CLIPMAP)) != 0 after surface resolution (DrawEnvCell @0x0059F170 -> DrawMesh @0x0059D4A0 arg4=1); untextured slots are constructed but not emitted; - cell batches carry SourceSurfaceIndex, RetailSurfaceMask, RawSurfaceType, IsCellShell, and fixed clockwise raster cull (RenderMeshSubset @0x0059CA10); authored sides_type is no longer stored as GPU cull. Prepared-mesh serializer gains the four fields; bake recipe 7 -> 8 with a FullRebuild migration; pak format stays 2 (pinned). Ordinary GfxObj extraction is unchanged. AP-234's register row and CellMesh unification land in chunk B. Core: 32 descriptor tests. Content: 170/170. Bake: 18/18. Launcher.Core: 365/365 (Lane!=Linux). Solution Release build 0 warnings / 0 errors. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
14d8fe6478
commit
acf172469e
15 changed files with 1746 additions and 296 deletions
|
|
@ -730,10 +730,84 @@ public sealed class MeshExtractor {
|
|||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-source-surface-array-index accumulator for
|
||||
/// <see cref="PrepareCellStructMeshData"/>. Retail's
|
||||
/// <c>D3DPolyRender::ConstructMesh</c> @0x0059DFA0 allocates one
|
||||
/// <c>MeshBatchType</c> triangle-attribute record and one
|
||||
/// <c>isStippledOrAlphaedMask</c> byte per EnvCell SURFACE-ARRAY SLOT
|
||||
/// (contract §3.2-§3.3), not per (TextureKey, sides_type) tuple. This
|
||||
/// class is that per-slot accumulator: every candidate from every
|
||||
/// polygon that targets this slot contributes to the SAME
|
||||
/// <see cref="Batch"/>, in source polygon order, regardless of the
|
||||
/// resolved Surface DID, texture format, or that polygon's own
|
||||
/// <c>sides_type</c>/stippling value.
|
||||
/// </summary>
|
||||
private sealed class CellSurfaceSlot {
|
||||
public CellSurfaceSlot(Surface surface, uint surfaceId) {
|
||||
Surface = surface;
|
||||
SurfaceId = surfaceId;
|
||||
}
|
||||
|
||||
/// <summary>Resolved once per slot; every candidate targeting this slot shares it.</summary>
|
||||
public Surface Surface { get; }
|
||||
public uint SurfaceId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Retail's <c>isStippledOrAlphaedMask</c> byte for this slot:
|
||||
/// <see cref="CellStructSideCandidates.InitialSurfaceMask"/> once,
|
||||
/// then <see cref="CellStructSideCandidates.ApplyStipplingMaskBit"/>
|
||||
/// per polygon candidate touching the slot (contract §3.2).
|
||||
/// </summary>
|
||||
public int Mask;
|
||||
|
||||
/// <summary>
|
||||
/// Null until the first candidate for this slot resolves/decodes its
|
||||
/// texture (lazy, exactly once per slot). Stays null forever if
|
||||
/// texture resolution failed a dependency lookup (already logged via
|
||||
/// the existing "[tex-skip]"/LogWarning diagnostics) — the slot then
|
||||
/// contributes zero geometry.
|
||||
/// </summary>
|
||||
public TextureBatchData? Batch;
|
||||
|
||||
/// <summary>(Width, Height, TextureFormat) storage-grouping key for <see cref="Batch"/>, set alongside it.</summary>
|
||||
public (int Width, int Height, TextureFormat Format) Format;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's exact built-EnvCell surface/subset construction, per
|
||||
/// docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md
|
||||
/// (OH2/S1 chunk 1-2). Side candidates come ONLY from
|
||||
/// <c>CPolygon::sides_type</c>
|
||||
/// (<see cref="CellStructSideCandidates.GetCandidates"/>,
|
||||
/// <c>D3DPolyRender::ConstructMesh</c> @0x0059DFA0, contract §3.4);
|
||||
/// <c>NoPos</c>/<c>NoNeg</c> mean "this side's UV-index array is absent"
|
||||
/// only (<c>CPolygon::UnPack</c> @0x00538650, contract §2.3/§3.5) and
|
||||
/// never suppress a candidate. The subset/material owner is the SOURCE
|
||||
/// SURFACE-ARRAY INDEX (contract §3.6), not the resolved Surface DID,
|
||||
/// texture format, or stippling — <see cref="CellSurfaceSlot"/> is that
|
||||
/// per-slot accumulator. A slot whose resolved <c>Surface.Type</c> is
|
||||
/// untextured (<see cref="RetailUntexturedSurfacePolicy.IsUntextured"/>)
|
||||
/// is fully constructed but NOT emitted into the prepared output,
|
||||
/// matching <c>RenderDeviceD3D::DrawEnvCell</c> @0x0059F170 →
|
||||
/// <c>D3DPolyRender::DrawMesh(..., arg4=1)</c> @0x0059D4A0's built-EnvCell
|
||||
/// admission test <c>(Surface.Type & (BASE1_IMAGE|BASE1_CLIPMAP)) != 0</c>
|
||||
/// (contract §4). Retires the AP-234 approximation this method used to
|
||||
/// carry (docs/architecture/retail-divergence-register.md).
|
||||
/// </summary>
|
||||
public ObjectMeshData? PrepareCellStructMeshData(ulong id, CellStruct cellStruct, IReadOnlyList<ushort> surfaceOverrides, Matrix4x4 transform, CancellationToken ct) {
|
||||
var vertices = new List<VertexPositionNormalTexture>();
|
||||
var UVLookup = new Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort>();
|
||||
// Vertex identity per contract §3.5: dedupe within one NORMAL-SIGN
|
||||
// LANE on (authored vertex id, UV index); positive- and
|
||||
// negative-normal copies never alias. Every branch row in the
|
||||
// retail table (§3.4) has UvSlot == SurfaceSlot, and a looked-up UV
|
||||
// coordinate is fully determined by (vertexId, uvIndex) regardless
|
||||
// of which array (pos_uv_indices/neg_uv_indices) supplied that
|
||||
// index — so NormalSign alone (encoded here as the boolean
|
||||
// "negativeLane") is a sufficient, exact lane key.
|
||||
var vertexLookup = new Dictionary<(ushort vertId, ushort uvIdx, bool negativeLane), ushort>();
|
||||
var batchesByFormat = new Dictionary<(int Width, int Height, TextureFormat Format), List<TextureBatchData>>();
|
||||
var slots = new Dictionary<int, CellSurfaceSlot>();
|
||||
|
||||
var min = new Vector3(float.MaxValue);
|
||||
var max = new Vector3(float.MinValue);
|
||||
|
|
@ -744,279 +818,346 @@ public sealed class MeshExtractor {
|
|||
}
|
||||
var boundingBox = new BoundingBox(min, max);
|
||||
|
||||
foreach (var poly in cellStruct.Polygons.Values) {
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (poly.VertexIds.Count < 3) continue;
|
||||
int unknownSidesTypePolygons = 0;
|
||||
|
||||
// Retail D3DPolyRender::ConstructMesh (0x0059dfa0) treats this
|
||||
// DatReaderWriter "CullMode" as CPolygon::sides_type, not as a
|
||||
// GL cull enum: 0 = pos, 1 = pos twice with reversed winding,
|
||||
// 2 = pos + neg surface. The DAT-side NoPos/NoNeg flags still
|
||||
// suppress hidden portal/cap faces before they reach our mesh.
|
||||
//
|
||||
// #426: unlike PrepareGfxObjMeshData (ordinary objects — fixed to
|
||||
// emit every NoPos-flagged positive side, since NoPos means "no
|
||||
// UVs", not "no face"), this NoPos gate is INTENTIONALLY kept.
|
||||
// Cell-wall geometry draws through retail's
|
||||
// RenderDeviceD3D::DrawEnvCell (@0x0059f170), which calls
|
||||
// D3DPolyRender::DrawMesh with arg4=1 and skips every UNTEXTURED
|
||||
// subset — approximated here by the polygon's own NoPos flag
|
||||
// rather than by resolving Surface.Type
|
||||
// (Base1Image/Base1ClipMap, see RetailUntexturedSurfacePolicy)
|
||||
// before this decision is made. See
|
||||
// docs/architecture/retail-divergence-register.md AP-234. Do NOT
|
||||
// remove this gate to mirror the GfxObj fix — that would draw
|
||||
// solid-colour cell-wall faces retail never shows.
|
||||
bool hasPos = !poly.Stippling.HasFlag(StipplingType.NoPos);
|
||||
bool hasNeg = !poly.Stippling.HasFlag(StipplingType.NoNeg);
|
||||
|
||||
if (hasPos)
|
||||
AddSurfaceToBatch(poly, poly.PosSurface, useNegUv: false, invertNormal: false, reverseWinding: false);
|
||||
if (hasPos && poly.SidesType == CullMode.None) {
|
||||
AddSurfaceToBatch(poly, poly.PosSurface, useNegUv: false, invertNormal: true, reverseWinding: true);
|
||||
bool TryResolveSlot(int slot, out Surface surface, out uint surfaceId) {
|
||||
if (slot < surfaceOverrides.Count) {
|
||||
surfaceId = 0x08000000u | surfaceOverrides[slot];
|
||||
}
|
||||
else if (hasNeg && poly.SidesType == CullMode.Clockwise) {
|
||||
AddSurfaceToBatch(poly, poly.NegSurface, useNegUv: true, invertNormal: true, reverseWinding: false);
|
||||
else {
|
||||
surface = default!;
|
||||
surfaceId = 0;
|
||||
_logger.LogWarning($"Failed to find surface override for index {slot} in CellStruct id=0x{id:X16}");
|
||||
return false;
|
||||
}
|
||||
|
||||
void AddSurfaceToBatch(Polygon poly, short surfaceIdx, bool useNegUv, bool invertNormal, bool reverseWinding) {
|
||||
if (surfaceIdx < 0) return;
|
||||
if (!_dats.Portal.TryGet<Surface>(surfaceId, out surface!)) {
|
||||
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
|
||||
Console.WriteLine($"[tex-skip] cellstruct Surface 0x{surfaceId:X8} miss -> slot {slot} dropped (cellstruct id=0x{id:X16})");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
uint surfaceId;
|
||||
if (surfaceIdx < surfaceOverrides.Count) {
|
||||
surfaceId = 0x08000000u | surfaceOverrides[surfaceIdx];
|
||||
}
|
||||
else {
|
||||
_logger.LogWarning($"Failed to find surface override for index {surfaceIdx} in CellStruct 0x{cellStruct:X4}");
|
||||
return;
|
||||
// Resolves and decodes the texture payload for ONE surface slot,
|
||||
// exactly once. Identical decode logic to the ordinary-GfxObj path
|
||||
// (PrepareGfxObjMeshData) — deliberately duplicated rather than
|
||||
// shared, so a future change to one extraction path cannot silently
|
||||
// change the other's behavior (contract §5.2's "Core and Content do
|
||||
// not retain divergent CellStruct interpretations" is about NOT
|
||||
// re-implementing the retail SIDE/SUBSET algorithm twice; the DAT
|
||||
// texture-decode plumbing itself is unrelated to that rule).
|
||||
void ResolveSlotBatch(CellSurfaceSlot state) {
|
||||
Surface surface = state.Surface;
|
||||
uint surfaceId = state.SurfaceId;
|
||||
|
||||
int texWidth, texHeight;
|
||||
byte[] textureData;
|
||||
TextureFormat textureFormat;
|
||||
UploadPixelFormat? uploadPixelFormat = null;
|
||||
UploadPixelType? uploadPixelType = null;
|
||||
// #426 / contract §4: "solid" (untextured) is a SURFACE fact.
|
||||
bool isSolid = RetailUntexturedSurfacePolicy.IsUntextured(surface.Type);
|
||||
bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap);
|
||||
uint paletteId = 0;
|
||||
bool isDxt3or5 = false;
|
||||
bool textureDataIsCached = false;
|
||||
DatReaderWriter.Enums.PixelFormat? sourceFormat = null;
|
||||
var isAdditive = false;
|
||||
var isTransparent = false;
|
||||
|
||||
if (isSolid) {
|
||||
texWidth = texHeight = 32;
|
||||
textureData = GetOrCreateSolidColorTexture(surface.ColorValue, texWidth, texHeight);
|
||||
textureFormat = TextureFormat.RGBA8;
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
textureDataIsCached = true;
|
||||
}
|
||||
else if (_dats.Portal.TryGet<SurfaceTexture>(surface.OrigTextureId, out var surfaceTexture)) {
|
||||
var renderSurfaceId = surfaceTexture.Textures.First();
|
||||
if (!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var renderSurface)) {
|
||||
if (!_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out var hrRenderSurface)) {
|
||||
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
|
||||
Console.WriteLine($"[tex-skip] cellstruct RenderSurface 0x{renderSurfaceId:X8} miss (portal+highres) -> WALL poly batch dropped");
|
||||
return;
|
||||
}
|
||||
renderSurface = hrRenderSurface;
|
||||
}
|
||||
|
||||
if (!_dats.Portal.TryGet<Surface>(surfaceId, out var surface)) {
|
||||
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
|
||||
Console.WriteLine($"[tex-skip] cellstruct Surface 0x{surfaceId:X8} miss -> WALL poly batch dropped (cellstruct 0x{cellStruct:X4})");
|
||||
return;
|
||||
texWidth = renderSurface.Width;
|
||||
texHeight = renderSurface.Height;
|
||||
paletteId = renderSurface.DefaultPaletteId;
|
||||
sourceFormat = renderSurface.Format;
|
||||
isDxt3or5 = renderSurface.Format is
|
||||
DatReaderWriter.Enums.PixelFormat.PFID_DXT3 or
|
||||
DatReaderWriter.Enums.PixelFormat.PFID_DXT5;
|
||||
var decodedTextureKey = CreateDecodedTextureKey(
|
||||
renderSurfaceId,
|
||||
renderSurface.Format,
|
||||
isClipMap,
|
||||
surface.Type.HasFlag(SurfaceType.Additive));
|
||||
|
||||
if (CanPreserveCompressedTexture(
|
||||
renderSurface.Format,
|
||||
isClipMap,
|
||||
surface.Translucency)) {
|
||||
textureData = renderSurface.SourceData;
|
||||
textureFormat = ToTextureFormat(renderSurface.Format);
|
||||
}
|
||||
|
||||
int texWidth, texHeight;
|
||||
byte[] textureData;
|
||||
TextureFormat textureFormat;
|
||||
UploadPixelFormat? uploadPixelFormat = null;
|
||||
UploadPixelType? uploadPixelType = null;
|
||||
// #426: "solid" (untextured) is a SURFACE fact, not a
|
||||
// polygon-stippling fact — see RetailUntexturedSurfacePolicy.
|
||||
// The old `NoPos ||` term conflated "this polygon's positive
|
||||
// side has no UVs" with "this surface is untextured"; it also
|
||||
// wrongly classified a NEG-side batch by the POS-side's NoPos
|
||||
// flag, since this method is shared by both sides.
|
||||
bool isSolid = RetailUntexturedSurfacePolicy.IsUntextured(surface.Type);
|
||||
bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap);
|
||||
uint paletteId = 0;
|
||||
bool isDxt3or5 = false;
|
||||
bool textureDataIsCached = false;
|
||||
DatReaderWriter.Enums.PixelFormat? sourceFormat = null;
|
||||
var isAdditive = false;
|
||||
var isTransparent = false;
|
||||
|
||||
if (isSolid) {
|
||||
texWidth = texHeight = 32;
|
||||
textureData = GetOrCreateSolidColorTexture(surface.ColorValue, texWidth, texHeight);
|
||||
else if (_decodedTextureCache.TryGet(decodedTextureKey, out var cachedData)) {
|
||||
textureData = cachedData;
|
||||
textureFormat = TextureFormat.RGBA8;
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
textureDataIsCached = true;
|
||||
}
|
||||
else if (_dats.Portal.TryGet<SurfaceTexture>(surface.OrigTextureId, out var surfaceTexture)) {
|
||||
var renderSurfaceId = surfaceTexture.Textures.First();
|
||||
if (!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var renderSurface)) {
|
||||
if (!_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out var hrRenderSurface)) {
|
||||
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
|
||||
Console.WriteLine($"[tex-skip] cellstruct RenderSurface 0x{renderSurfaceId:X8} miss (portal+highres) -> WALL poly batch dropped");
|
||||
return;
|
||||
}
|
||||
renderSurface = hrRenderSurface;
|
||||
}
|
||||
|
||||
texWidth = renderSurface.Width;
|
||||
texHeight = renderSurface.Height;
|
||||
paletteId = renderSurface.DefaultPaletteId;
|
||||
sourceFormat = renderSurface.Format;
|
||||
isDxt3or5 = renderSurface.Format is
|
||||
DatReaderWriter.Enums.PixelFormat.PFID_DXT3 or
|
||||
DatReaderWriter.Enums.PixelFormat.PFID_DXT5;
|
||||
var decodedTextureKey = CreateDecodedTextureKey(
|
||||
renderSurfaceId,
|
||||
renderSurface.Format,
|
||||
isClipMap,
|
||||
surface.Type.HasFlag(SurfaceType.Additive));
|
||||
|
||||
if (CanPreserveCompressedTexture(
|
||||
renderSurface.Format,
|
||||
isClipMap,
|
||||
surface.Translucency)) {
|
||||
textureData = renderSurface.SourceData;
|
||||
textureFormat = ToTextureFormat(renderSurface.Format);
|
||||
}
|
||||
else if (_decodedTextureCache.TryGet(decodedTextureKey, out var cachedData)) {
|
||||
textureData = cachedData;
|
||||
else {
|
||||
if (TextureHelpers.IsCompressedFormat(renderSurface.Format)) {
|
||||
isDxt3or5 = renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT5;
|
||||
textureFormat = TextureFormat.RGBA8;
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
textureDataIsCached = true;
|
||||
|
||||
textureData = _decodedTextureCache.GetOrCreate(
|
||||
decodedTextureKey,
|
||||
() => DecodeCompressedTexture(renderSurface, texWidth, texHeight),
|
||||
out textureDataIsCached);
|
||||
}
|
||||
else {
|
||||
if (TextureHelpers.IsCompressedFormat(renderSurface.Format)) {
|
||||
isDxt3or5 = renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT5;
|
||||
textureFormat = TextureFormat.RGBA8;
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
|
||||
textureData = _decodedTextureCache.GetOrCreate(
|
||||
decodedTextureKey,
|
||||
() => DecodeCompressedTexture(renderSurface, texWidth, texHeight),
|
||||
out textureDataIsCached);
|
||||
}
|
||||
else {
|
||||
textureFormat = TextureFormat.RGBA8;
|
||||
textureData = renderSurface.SourceData;
|
||||
switch (renderSurface.Format) {
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8:
|
||||
textureData = new byte[texWidth * texHeight * 4];
|
||||
TextureHelpers.FillA8R8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
break;
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_R8G8B8:
|
||||
textureData = new byte[texWidth * texHeight * 4];
|
||||
TextureHelpers.FillR8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
break;
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_INDEX16:
|
||||
if (!_dats.Portal.TryGet<Palette>(renderSurface.DefaultPaletteId, out var paletteData)) return;
|
||||
textureData = new byte[texWidth * texHeight * 4];
|
||||
TextureHelpers.FillIndex16(renderSurface.SourceData, paletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap);
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
break;
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_P8:
|
||||
if (!_dats.Portal.TryGet<Palette>(renderSurface.DefaultPaletteId, out var p8PaletteData)) return;
|
||||
textureData = new byte[texWidth * texHeight * 4];
|
||||
TextureHelpers.FillP8(renderSurface.SourceData, p8PaletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap);
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
break;
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_R5G6B5:
|
||||
textureData = new byte[texWidth * texHeight * 4];
|
||||
TextureHelpers.FillR5G6B5(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
break;
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4:
|
||||
textureData = new byte[texWidth * texHeight * 4];
|
||||
TextureHelpers.FillA4R4G4B4(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
break;
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_A8:
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA:
|
||||
textureData = new byte[texWidth * texHeight * 4];
|
||||
if (surface.Type.HasFlag(SurfaceType.Additive)) {
|
||||
TextureHelpers.FillA8Additive(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
||||
}
|
||||
else {
|
||||
TextureHelpers.FillA8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
||||
}
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
break;
|
||||
default: return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!TextureHelpers.IsCompressedFormat(renderSurface.Format))
|
||||
{
|
||||
textureData = _decodedTextureCache.RetainOrUse(
|
||||
decodedTextureKey,
|
||||
textureData,
|
||||
out textureDataIsCached);
|
||||
textureFormat = TextureFormat.RGBA8;
|
||||
textureData = renderSurface.SourceData;
|
||||
switch (renderSurface.Format) {
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8:
|
||||
textureData = new byte[texWidth * texHeight * 4];
|
||||
TextureHelpers.FillA8R8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
break;
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_R8G8B8:
|
||||
textureData = new byte[texWidth * texHeight * 4];
|
||||
TextureHelpers.FillR8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
break;
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_INDEX16:
|
||||
if (!_dats.Portal.TryGet<Palette>(renderSurface.DefaultPaletteId, out var paletteData)) return;
|
||||
textureData = new byte[texWidth * texHeight * 4];
|
||||
TextureHelpers.FillIndex16(renderSurface.SourceData, paletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap);
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
break;
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_P8:
|
||||
if (!_dats.Portal.TryGet<Palette>(renderSurface.DefaultPaletteId, out var p8PaletteData)) return;
|
||||
textureData = new byte[texWidth * texHeight * 4];
|
||||
TextureHelpers.FillP8(renderSurface.SourceData, p8PaletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap);
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
break;
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_R5G6B5:
|
||||
textureData = new byte[texWidth * texHeight * 4];
|
||||
TextureHelpers.FillR5G6B5(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
break;
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4:
|
||||
textureData = new byte[texWidth * texHeight * 4];
|
||||
TextureHelpers.FillA4R4G4B4(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
break;
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_A8:
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA:
|
||||
textureData = new byte[texWidth * texHeight * 4];
|
||||
if (surface.Type.HasFlag(SurfaceType.Additive)) {
|
||||
TextureHelpers.FillA8Additive(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
||||
}
|
||||
else {
|
||||
TextureHelpers.FillA8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
||||
}
|
||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||
break;
|
||||
default: return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isClipMap && textureData != null) {
|
||||
// If we got this from the cache, we need to clone it so we don't scale the cached raw data
|
||||
if (textureDataIsCached) {
|
||||
var clonedData = new byte[textureData.Length];
|
||||
System.Buffer.BlockCopy(textureData, 0, clonedData, 0, textureData.Length);
|
||||
textureData = clonedData;
|
||||
textureDataIsCached = false;
|
||||
}
|
||||
if (!TextureHelpers.IsCompressedFormat(renderSurface.Format))
|
||||
{
|
||||
textureData = _decodedTextureCache.RetainOrUse(
|
||||
decodedTextureKey,
|
||||
textureData,
|
||||
out textureDataIsCached);
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < textureData.Length; i += 4) {
|
||||
if (textureData[i] == 0 && textureData[i + 1] == 0 && textureData[i + 2] == 0) {
|
||||
textureData[i + 3] = 0;
|
||||
}
|
||||
if (isClipMap && textureData != null) {
|
||||
// If we got this from the cache, we need to clone it so we don't scale the cached raw data
|
||||
if (textureDataIsCached) {
|
||||
var clonedData = new byte[textureData.Length];
|
||||
System.Buffer.BlockCopy(textureData, 0, clonedData, 0, textureData.Length);
|
||||
textureData = clonedData;
|
||||
textureDataIsCached = false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < textureData.Length; i += 4) {
|
||||
if (textureData[i] == 0 && textureData[i + 1] == 0 && textureData[i + 2] == 0) {
|
||||
textureData[i + 3] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
|
||||
Console.WriteLine($"[tex-skip] cellstruct SurfaceTexture 0x{surface.OrigTextureId:X8} miss -> WALL poly batch dropped (surface 0x{surfaceId:X8})");
|
||||
return;
|
||||
}
|
||||
}
|
||||
else {
|
||||
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
|
||||
Console.WriteLine($"[tex-skip] cellstruct SurfaceTexture 0x{surface.OrigTextureId:X8} miss -> WALL poly batch dropped (surface 0x{surfaceId:X8})");
|
||||
return;
|
||||
}
|
||||
|
||||
isAdditive = !isSolid && surface.Type.HasFlag(SurfaceType.Additive);
|
||||
isTransparent = isSolid ? surface.ColorValue.Alpha < 255 :
|
||||
(surface.Type.HasFlag(SurfaceType.Translucent) ||
|
||||
surface.Type.HasFlag(SurfaceType.Base1ClipMap) ||
|
||||
((uint)surface.Type & 0x100) != 0 || // Alpha
|
||||
((uint)surface.Type & 0x200) != 0 || // InvAlpha
|
||||
isAdditive ||
|
||||
(surface.Translucency > 0.0f && surface.Translucency < 1.0f) ||
|
||||
textureFormat == TextureFormat.A8 ||
|
||||
textureFormat == TextureFormat.Rgba32f ||
|
||||
isDxt3or5 ||
|
||||
(sourceFormat != null && (sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8 ||
|
||||
sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4 ||
|
||||
sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 ||
|
||||
sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_DXT5)));
|
||||
isAdditive = !isSolid && surface.Type.HasFlag(SurfaceType.Additive);
|
||||
isTransparent = isSolid ? surface.ColorValue.Alpha < 255 :
|
||||
(surface.Type.HasFlag(SurfaceType.Translucent) ||
|
||||
surface.Type.HasFlag(SurfaceType.Base1ClipMap) ||
|
||||
((uint)surface.Type & 0x100) != 0 || // Alpha
|
||||
((uint)surface.Type & 0x200) != 0 || // InvAlpha
|
||||
isAdditive ||
|
||||
(surface.Translucency > 0.0f && surface.Translucency < 1.0f) ||
|
||||
textureFormat == TextureFormat.A8 ||
|
||||
textureFormat == TextureFormat.Rgba32f ||
|
||||
isDxt3or5 ||
|
||||
(sourceFormat != null && (sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8 ||
|
||||
sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4 ||
|
||||
sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 ||
|
||||
sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_DXT5)));
|
||||
|
||||
var format = (texWidth, texHeight, textureFormat);
|
||||
var key = new TextureKey {
|
||||
state.Batch = new TextureBatchData {
|
||||
Key = new TextureKey {
|
||||
SurfaceId = surfaceId,
|
||||
PaletteId = paletteId,
|
||||
Stippling = poly.Stippling,
|
||||
IsSolid = isSolid
|
||||
};
|
||||
// Contract §3.6 point 3 / §8.2: the subset owner is the
|
||||
// SLOT, not the TextureKey, so per-polygon Stippling
|
||||
// must not be able to split (or wrongly merge) a slot's
|
||||
// one texture payload. Fixed None here; the real,
|
||||
// per-polygon-aggregated retail mask lives in
|
||||
// RetailSurfaceMask instead.
|
||||
Stippling = StipplingType.None,
|
||||
IsSolid = isSolid,
|
||||
},
|
||||
TextureData = textureData!,
|
||||
UploadPixelFormat = uploadPixelFormat,
|
||||
UploadPixelType = uploadPixelType,
|
||||
Translucency = TranslucencyKindExtensions.FromSurfaceType(surface.Type),
|
||||
IsTransparent = isTransparent,
|
||||
IsAdditive = isAdditive,
|
||||
};
|
||||
state.Format = (texWidth, texHeight, textureFormat);
|
||||
}
|
||||
|
||||
if (!batchesByFormat.TryGetValue(format, out var batches)) {
|
||||
batches = new List<TextureBatchData>();
|
||||
batchesByFormat[format] = batches;
|
||||
}
|
||||
foreach (var poly in cellStruct.Polygons.Values) {
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (poly.VertexIds.Count < 3) continue;
|
||||
|
||||
var batch = batches.FirstOrDefault(b => b.Key.Equals(key) && b.CullMode == poly.SidesType);
|
||||
if (batch == null) {
|
||||
batch = new TextureBatchData {
|
||||
Key = key,
|
||||
CullMode = poly.SidesType,
|
||||
TextureData = textureData!,
|
||||
UploadPixelFormat = uploadPixelFormat,
|
||||
UploadPixelType = uploadPixelType,
|
||||
Translucency =
|
||||
TranslucencyKindExtensions.FromSurfaceType(
|
||||
surface.Type),
|
||||
IsTransparent = isTransparent,
|
||||
IsAdditive = isAdditive
|
||||
// OH2/S1 chunk-1: side candidates come ONLY from sides_type
|
||||
// (CellStructSideCandidates.GetCandidates, contract §3.4). This
|
||||
// DatReaderWriter "CullMode" field IS the raw retail
|
||||
// sides_type integer (0/1/2), not a GPU cull enum — see
|
||||
// CellStructSideCandidates' own remarks. NoPos/NoNeg (below,
|
||||
// IsUvAbsent) govern UV-array absence only (§3.5) and no
|
||||
// longer suppress a candidate.
|
||||
ReadOnlySpan<CellStructSideCandidate> candidates =
|
||||
CellStructSideCandidates.GetCandidates((int)poly.SidesType);
|
||||
if (candidates.Length == 0) {
|
||||
// Contract §3.4 closing paragraph: only 0/1/2 are
|
||||
// retail-defined. Quarantine/report, don't invent a shape.
|
||||
unknownSidesTypePolygons++;
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var candidate in candidates) {
|
||||
short surfaceIdxRaw = candidate.SurfaceSlot == CellStructPolygonSurfaceSide.Positive
|
||||
? poly.PosSurface
|
||||
: poly.NegSurface;
|
||||
if (surfaceIdxRaw < 0) continue;
|
||||
int slot = surfaceIdxRaw;
|
||||
|
||||
if (!slots.TryGetValue(slot, out var slotState)) {
|
||||
if (!TryResolveSlot(slot, out var surface, out var surfaceId)) continue;
|
||||
slotState = new CellSurfaceSlot(surface, surfaceId) {
|
||||
Mask = CellStructSideCandidates.InitialSurfaceMask(surface.Type),
|
||||
};
|
||||
batches.Add(batch);
|
||||
slots[slot] = slotState;
|
||||
}
|
||||
|
||||
// Helper for CellStruct vertices
|
||||
bool batchHasWrappingUVs = batch.HasWrappingUVs;
|
||||
// Contract §3.2: the per-polygon mask OR always targets the
|
||||
// POSITIVE surface slot regardless of which specific
|
||||
// stippling bits are set — ApplyStipplingMaskBit already
|
||||
// encodes that via candidate.SurfaceSlot (a no-op when this
|
||||
// candidate's SurfaceSlot is Negative).
|
||||
slotState.Mask = CellStructSideCandidates.ApplyStipplingMaskBit(
|
||||
slotState.Mask, candidate.SurfaceSlot, poly.Stippling);
|
||||
|
||||
if (slotState.Batch is null) {
|
||||
ResolveSlotBatch(slotState);
|
||||
}
|
||||
if (slotState.Batch is null) continue; // texture resolution failed a dependency lookup; already logged.
|
||||
|
||||
bool useNegUv = candidate.UvSlot == CellStructPolygonSurfaceSide.Negative;
|
||||
bool invertNormal = candidate.NormalSign < 0;
|
||||
bool uvAbsent = CellStructSideCandidates.IsUvAbsent(candidate, poly.Stippling);
|
||||
|
||||
bool hasWrappingUVs = slotState.Batch.HasWrappingUVs;
|
||||
BuildCellStructPolygonIndices(
|
||||
poly,
|
||||
cellStruct,
|
||||
UVLookup,
|
||||
vertexLookup,
|
||||
vertices,
|
||||
batch.Indices,
|
||||
slotState.Batch.Indices,
|
||||
useNegUv,
|
||||
uvAbsent,
|
||||
invertNormal,
|
||||
reverseWinding,
|
||||
candidate.ReverseWinding,
|
||||
transform,
|
||||
ref batchHasWrappingUVs);
|
||||
batch.HasWrappingUVs = batchHasWrappingUVs;
|
||||
ref hasWrappingUVs);
|
||||
slotState.Batch.HasWrappingUVs = hasWrappingUVs;
|
||||
}
|
||||
}
|
||||
|
||||
int skippedUntexturedSlots = 0;
|
||||
foreach (var slot in slots.Keys.OrderBy(s => s)) {
|
||||
var state = slots[slot];
|
||||
if (state.Batch is null) continue;
|
||||
|
||||
var batch = state.Batch;
|
||||
batch.SourceSurfaceIndex = slot;
|
||||
batch.RawSurfaceType = (uint)state.Surface.Type;
|
||||
batch.RetailSurfaceMask = (byte)state.Mask;
|
||||
batch.IsCellShell = true;
|
||||
// Contract §3.6 + EnvCellRenderer.Rhi.cs's
|
||||
// ResolveRetailCellShellCullMode: geometry is already
|
||||
// fan-expanded per side/copy candidate (both fan directions
|
||||
// materialized as real triangles, see BuildCellStructPolygonIndices),
|
||||
// so every constructed cell shell subset draws with retail's
|
||||
// fixed D3DCULL_CW (RenderMeshSubset @0x0059CA10). The authored
|
||||
// sides_type is NOT stored here any more.
|
||||
batch.CullMode = CullMode.Clockwise;
|
||||
|
||||
// Contract §4: built-EnvCell DrawMesh(arg4=1) admits a subset
|
||||
// iff (Surface.Type & (BASE1_IMAGE|BASE1_CLIPMAP)) != 0. An
|
||||
// untextured slot is fully constructed above (its mask/
|
||||
// triangle-ownership facts exist) but is not emitted into the
|
||||
// prepared output.
|
||||
if (RetailUntexturedSurfacePolicy.IsUntextured(state.Surface.Type)) {
|
||||
skippedUntexturedSlots++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!batchesByFormat.TryGetValue(state.Format, out var list)) {
|
||||
list = new List<TextureBatchData>();
|
||||
batchesByFormat[state.Format] = list;
|
||||
}
|
||||
list.Add(batch);
|
||||
}
|
||||
|
||||
if (unknownSidesTypePolygons > 0) {
|
||||
_logger.LogWarning(
|
||||
"CellStruct id=0x{Id:X16}: {Count} polygon(s) had an unrecognized raw sides_type (only 0/1/2 are retail-defined per OH2 contract §3.4); zero geometry candidates were constructed for them.",
|
||||
id, unknownSidesTypePolygons);
|
||||
}
|
||||
if (skippedUntexturedSlots > 0) {
|
||||
_logger.LogDebug(
|
||||
"CellStruct id=0x{Id:X16}: {Count} surface slot(s) constructed but not emitted — untextured under retail's built-EnvCell admission (Surface.Type & 6) == 0 (OH2 contract §4).",
|
||||
id, skippedUntexturedSlots);
|
||||
}
|
||||
|
||||
return new ObjectMeshData {
|
||||
ObjectId = id,
|
||||
IsSetup = false,
|
||||
|
|
@ -1028,44 +1169,73 @@ public sealed class MeshExtractor {
|
|||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds one candidate's fan vertices/indices for a CellStruct polygon
|
||||
/// and appends them to <paramref name="indices"/>. Vertex identity and
|
||||
/// UV-absence fallback follow contract §3.5 as arbitrated on the binary:
|
||||
/// <c>copyVert</c> @0x0059C080 multiplies the authored normal by the
|
||||
/// selected sign; an absent polygon UV-index array means UV INDEX 0
|
||||
/// (the caller zeroes the index register, <c>ConstructMesh</c>
|
||||
/// @0x0059E691 <c>xor ebx,ebx</c>), and the coordinate is zeroed only
|
||||
/// when the vertex has no UV array, the index is negative, or the index
|
||||
/// is out of the vertex's range. Fan winding is
|
||||
/// <see cref="CellStructSideCandidates.TriangleFanIndices"/> exactly
|
||||
/// (contract §3.4's forward/reversed table), not a re-derived formula.
|
||||
/// </summary>
|
||||
private void BuildCellStructPolygonIndices(Polygon poly, CellStruct cellStruct,
|
||||
Dictionary<(ushort vertId, ushort uvIdx, bool invertNormal), ushort> UVLookup,
|
||||
Dictionary<(ushort vertId, ushort uvIdx, bool negativeLane), ushort> vertexLookup,
|
||||
List<VertexPositionNormalTexture> vertices, List<ushort> indices,
|
||||
bool useNegUv, bool invertNormal, bool reverseWinding,
|
||||
bool useNegUv, bool uvAbsent, bool invertNormal, bool reverseWinding,
|
||||
Matrix4x4 transform, ref bool hasWrappingUVs) {
|
||||
|
||||
var polyIndices = new List<ushort>();
|
||||
|
||||
for (int i = 0; i < poly.VertexIds.Count; i++) {
|
||||
ushort vertId = (ushort)poly.VertexIds[i];
|
||||
ushort uvIdx = 0;
|
||||
|
||||
if (useNegUv && poly.NegUVIndices != null && i < poly.NegUVIndices.Count)
|
||||
uvIdx = poly.NegUVIndices[i];
|
||||
else if (poly.PosUVIndices != null && i < poly.PosUVIndices.Count)
|
||||
uvIdx = poly.PosUVIndices[i];
|
||||
// Retail's UV-index selection, arbitrated 2026-09-02 on the
|
||||
// PDB-paired binary (ConstructMesh @0x0059E683-0x0059E693):
|
||||
// mov edi,[uv-index array]; test edi,edi; je -> xor ebx,ebx
|
||||
// else movsx ebx, byte ptr [edx+edi]
|
||||
// so an ABSENT polygon UV-index array (NoPos/NoNeg, contract
|
||||
// §3.5) yields UV INDEX 0 — copyVert @0x0059C080 then reads the
|
||||
// vertex's own UV slot 0 like any other index. copyVert zeroes
|
||||
// the coordinate only when the index is negative (the array
|
||||
// element is a SIGNED char: movsx), the index is >= the vertex's
|
||||
// uv count, or the vertex has no UV array at all. It never
|
||||
// clamps an out-of-range index to slot 0.
|
||||
int uvIdxSigned = 0;
|
||||
if (!uvAbsent) {
|
||||
if (useNegUv && poly.NegUVIndices != null && i < poly.NegUVIndices.Count)
|
||||
uvIdxSigned = unchecked((sbyte)(byte)poly.NegUVIndices[i]);
|
||||
else if (!useNegUv && poly.PosUVIndices != null && i < poly.PosUVIndices.Count)
|
||||
uvIdxSigned = unchecked((sbyte)(byte)poly.PosUVIndices[i]);
|
||||
}
|
||||
|
||||
if (!cellStruct.VertexArray.Vertices.TryGetValue(vertId, out var vertex)) continue;
|
||||
|
||||
if (uvIdx >= vertex.UVs.Count) {
|
||||
uvIdx = 0;
|
||||
}
|
||||
bool uvInRange = uvIdxSigned >= 0 && uvIdxSigned < vertex.UVs.Count;
|
||||
Vector2 uv = uvInRange
|
||||
? new Vector2(vertex.UVs[uvIdxSigned].U, vertex.UVs[uvIdxSigned].V)
|
||||
: Vector2.Zero;
|
||||
|
||||
var key = (vertId, uvIdx, invertNormal);
|
||||
// Vertex-identity key (contract §3.5): (sign lane, UV index,
|
||||
// authored vertex id). Retail keys on the same signed index it
|
||||
// feeds copyVert, so an absent-array read and a real index-0
|
||||
// read share one key AND one coordinate (vertex UV slot 0);
|
||||
// there is no writer-order question. A negative (corrupt) index
|
||||
// is kept distinct from slot 0 by folding it to a reserved key
|
||||
// value rather than aliasing real slot-0 data.
|
||||
ushort uvKey = uvIdxSigned >= 0 ? (ushort)uvIdxSigned : ushort.MaxValue;
|
||||
var key = (vertId, uvKey, invertNormal);
|
||||
|
||||
if (!hasWrappingUVs) {
|
||||
var uvCheck = vertex.UVs.Count > 0
|
||||
? new Vector2(vertex.UVs[uvIdx].U, vertex.UVs[uvIdx].V)
|
||||
: Vector2.Zero;
|
||||
if (uvCheck.X < 0f || uvCheck.X > 1f || uvCheck.Y < 0f || uvCheck.Y > 1f) {
|
||||
if (!hasWrappingUVs && uvInRange) {
|
||||
if (uv.X < 0f || uv.X > 1f || uv.Y < 0f || uv.Y > 1f) {
|
||||
hasWrappingUVs = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!UVLookup.TryGetValue(key, out var idx)) {
|
||||
var uv = vertex.UVs.Count > 0
|
||||
? new Vector2(vertex.UVs[uvIdx].U, vertex.UVs[uvIdx].V)
|
||||
: Vector2.Zero;
|
||||
if (!vertexLookup.TryGetValue(key, out var idx)) {
|
||||
|
||||
var normal = Vector3.Normalize(Vector3.TransformNormal(vertex.Normal, transform));
|
||||
if (invertNormal) {
|
||||
|
|
@ -1078,24 +1248,20 @@ public sealed class MeshExtractor {
|
|||
normal,
|
||||
uv
|
||||
));
|
||||
UVLookup[key] = idx;
|
||||
vertexLookup[key] = idx;
|
||||
}
|
||||
polyIndices.Add(idx);
|
||||
}
|
||||
|
||||
if (reverseWinding) {
|
||||
for (int i = 2; i < polyIndices.Count; i++) {
|
||||
indices.Add(polyIndices[i]);
|
||||
indices.Add(polyIndices[i - 1]);
|
||||
indices.Add(polyIndices[0]);
|
||||
}
|
||||
}
|
||||
else {
|
||||
for (int i = 2; i < polyIndices.Count; i++) {
|
||||
indices.Add(polyIndices[0]);
|
||||
indices.Add(polyIndices[i - 1]);
|
||||
indices.Add(polyIndices[i]);
|
||||
}
|
||||
// CellStructSideCandidates.TriangleFanIndices is the single source
|
||||
// of truth for retail's forward/reversed fan order (contract §3.4);
|
||||
// this loop drives it rather than re-deriving the index arithmetic.
|
||||
int triangleCount = polyIndices.Count - 2;
|
||||
for (int t = 0; t < triangleCount; t++) {
|
||||
var (a, b, c) = CellStructSideCandidates.TriangleFanIndices(t, reverseWinding);
|
||||
indices.Add(polyIndices[a]);
|
||||
indices.Add(polyIndices[b]);
|
||||
indices.Add(polyIndices[c]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue