Three fixes to match retail CLandBlock::get_land_scenes (0x00530460): 1. Loop bound: iterate 9×9 vertices (side_vertex_count=9), not 8×8 cells. Edge vertices (x=8 or y=8) produce valid spawns when the per-object displacement shifts the position back into [0, 192). Confirmed by named retail decomp do-while condition, WorldBuilder vertLength=9, ACViewer Terrain.Count=81, AC2D wTopo[9][9]. 2. Building suppression: check at the DISPLACED position's cell (CSortCell::has_building per spawn), not at the loop vertex index. Matches WorldBuilder buildingsGrid[gx2, gy2] pattern. 3. Slope filter: replace finite-difference gradient approximation with triangle-aware normal sampling via new static method TerrainSurface.SampleNormalZFromHeightmap. Picks the correct triangle via IsSplitSWtoNE, matching retail find_terrain_poly → polygon->plane.N.z and WorldBuilder's GetNormal(). Tests: 5 new tests for SampleNormalZFromHeightmap (flat=1.0, sloped<1, cross-validates with SampleSurface instance method) and DisplaceObject edge-vertex validity. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
528 lines
22 KiB
C#
528 lines
22 KiB
C#
using System;
|
||
using System.Numerics;
|
||
|
||
namespace AcDream.Core.Physics;
|
||
|
||
public readonly record struct TerrainSurfacePolygon(
|
||
float Z,
|
||
Vector3 Normal,
|
||
Vector3[] Vertices);
|
||
|
||
/// <summary>
|
||
/// Outdoor terrain height resolver for a single landblock. Performs
|
||
/// per-triangle barycentric Z interpolation matching the visual terrain
|
||
/// mesh's triangle split direction (AC2D's FSplitNESW formula).
|
||
///
|
||
/// <para>
|
||
/// Each cell (24×24 units) is split into two triangles along either the
|
||
/// SW→NE or SE→NW diagonal. The split direction is determined by the
|
||
/// same formula the render mesh uses (0x0CCAC033 constants from AC2D),
|
||
/// so the Z this class produces matches the visual terrain surface exactly.
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed class TerrainSurface
|
||
{
|
||
private const int HeightmapSide = 9;
|
||
private const float CellSize = 24f;
|
||
private const int CellsPerSide = 8; // 192 / 24
|
||
|
||
private readonly float[,] _z; // pre-resolved heights [x, y]
|
||
private readonly bool[,] _cornerIsWater; // per-VERTEX water flag [x, y] — SurfChar[(type >> 2) & 0x1F]
|
||
private readonly byte[,] _cellWaterType; // per-CELL 0=NotWater, 1=Partially, 2=Entirely [cx, cy]
|
||
private readonly uint _landblockX;
|
||
private readonly uint _landblockY;
|
||
|
||
public TerrainSurface(byte[] heights, float[] heightTable,
|
||
uint landblockX = 0, uint landblockY = 0,
|
||
byte[]? terrainTypes = null)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(heights);
|
||
ArgumentNullException.ThrowIfNull(heightTable);
|
||
if (heights.Length < 81)
|
||
throw new ArgumentException("heights must have 81 entries", nameof(heights));
|
||
if (heightTable.Length < 256)
|
||
throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable));
|
||
|
||
_landblockX = landblockX;
|
||
_landblockY = landblockY;
|
||
|
||
// Pre-resolve all 81 heights so SampleZ is a pure lookup + lerp.
|
||
_z = new float[HeightmapSide, HeightmapSide];
|
||
for (int x = 0; x < HeightmapSide; x++)
|
||
for (int y = 0; y < HeightmapSide; y++)
|
||
_z[x, y] = heightTable[heights[x * HeightmapSide + y]];
|
||
|
||
// Per-vertex water flag. TerrainType lives in bits 2-6 of each
|
||
// TerrainInfo byte; water is types 0x10-0x14 inclusive (per
|
||
// LandDefs.TerrainType enum: WaterRunning..WaterDeepSea).
|
||
// Retail's SurfChar[32] lookup marks those 5 indices as water.
|
||
// If terrainTypes is not provided (tests / legacy callers) we
|
||
// default to "no water anywhere" which preserves old behavior.
|
||
_cornerIsWater = new bool[HeightmapSide, HeightmapSide];
|
||
if (terrainTypes is not null && terrainTypes.Length >= 81)
|
||
{
|
||
for (int x = 0; x < HeightmapSide; x++)
|
||
for (int y = 0; y < HeightmapSide; y++)
|
||
{
|
||
int typeBits = (terrainTypes[x * HeightmapSide + y] >> 2) & 0x1F;
|
||
_cornerIsWater[x, y] = typeBits >= 0x10 && typeBits <= 0x14;
|
||
}
|
||
}
|
||
|
||
// Per-cell water classification (mirrors ACE
|
||
// LandblockStruct.CalcCellWater / CalcWater). A cell has 4
|
||
// vertex corners; count how many are water type.
|
||
_cellWaterType = new byte[CellsPerSide, CellsPerSide];
|
||
for (int cx = 0; cx < CellsPerSide; cx++)
|
||
for (int cy = 0; cy < CellsPerSide; cy++)
|
||
{
|
||
int waterCorners = 0;
|
||
if (_cornerIsWater[cx, cy ]) waterCorners++;
|
||
if (_cornerIsWater[cx + 1, cy ]) waterCorners++;
|
||
if (_cornerIsWater[cx + 1, cy + 1]) waterCorners++;
|
||
if (_cornerIsWater[cx, cy + 1]) waterCorners++;
|
||
|
||
_cellWaterType[cx, cy] = waterCorners switch
|
||
{
|
||
0 => 0, // NotWater
|
||
4 => 2, // EntirelyWater
|
||
_ => 1, // PartiallyWater
|
||
};
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Triangle-aware terrain Z at (localX, localY) in landblock-local
|
||
/// coordinates (0..192 range). Uses the decompiled retail client formula
|
||
/// (FUN_00532a50 / ACE LandblockStruct.ConstructPolygons) to pick one of
|
||
/// two diagonals, then does barycentric interpolation inside the chosen
|
||
/// triangle. Cross-verified against ACE's <c>LandCell.find_terrain_poly</c>
|
||
/// (plane-equation based), both produce identical Z for every (localX,localY).
|
||
///
|
||
/// <para>
|
||
/// Triangle layout matches ACE's ConstructPolygons (lines 221-244):
|
||
/// <b>SWtoNE</b> (bit31 set, <c>SWtoNEcut = true</c>): diagonal runs
|
||
/// <b>BL → TR</b> (line y = x). Triangles: {BL,BR,TR} below,
|
||
/// {BL,TR,TL} above. Dividing test: <c>tx > ty</c>.
|
||
/// <b>SEtoNW</b> (bit31 clear, <c>SWtoNEcut = false</c>): diagonal runs
|
||
/// <b>BR → TL</b> (line x + y = 1). Triangles: {BL,BR,TL} below,
|
||
/// {BR,TR,TL} above. Dividing test: <c>tx + ty <= 1</c>.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Diagnosed 2026-04-21: previous version had the two enum branches'
|
||
/// geometry inverted — when <c>splitSWtoNE</c> was <c>true</c> we
|
||
/// interpolated across the NW-SE diagonal (ACE's SEtoNW geometry) and
|
||
/// vice versa. Symptom: remote players drawn at server Z hovered up to
|
||
/// ~1m above or clipped into the rendered ground on sloped cells
|
||
/// because our surface Z came from the wrong triangle of the cell quad.
|
||
/// Flat cells masked the bug because all four corners shared one Z.
|
||
/// </para>
|
||
/// </summary>
|
||
public float SampleZ(float localX, float localY)
|
||
{
|
||
// Which cell?
|
||
float fx = Math.Clamp(localX / CellSize, 0f, CellsPerSide - 0.001f);
|
||
float fy = Math.Clamp(localY / CellSize, 0f, CellsPerSide - 0.001f);
|
||
int cx = (int)fx;
|
||
int cy = (int)fy;
|
||
cx = Math.Clamp(cx, 0, CellsPerSide - 1);
|
||
cy = Math.Clamp(cy, 0, CellsPerSide - 1);
|
||
|
||
// Fractional position within the cell [0, 1]
|
||
float tx = fx - cx;
|
||
float ty = fy - cy;
|
||
|
||
// Four corner heights (BL=SW, BR=SE, TR=NE, TL=NW)
|
||
float hBL = _z[cx, cy ];
|
||
float hBR = _z[cx + 1, cy ];
|
||
float hTR = _z[cx + 1, cy + 1];
|
||
float hTL = _z[cx, cy + 1];
|
||
|
||
// Split direction — same formula as TerrainBlending.CalculateSplitDirection
|
||
// and ACE's LandblockStruct.ConstructPolygons.
|
||
bool splitSWtoNE = IsSplitSWtoNE(_landblockX, (uint)cx, _landblockY, (uint)cy);
|
||
|
||
return InterpolateZInTriangle(hBL, hBR, hTR, hTL, tx, ty, splitSWtoNE);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sample terrain Z directly from a landblock's raw heightmap. Same
|
||
/// algorithm as <see cref="SampleZ"/> (instance), but reads the four
|
||
/// corner heights through <c>heightTable[heights[x*9+y]]</c> on the fly
|
||
/// instead of from the pre-resolved instance cache. Use this when a
|
||
/// <see cref="TerrainSurface"/> hasn't been built yet for a landblock —
|
||
/// e.g. scenery hydration during streaming, before physics has registered
|
||
/// the landblock. Both paths produce the same Z, so scenery sits flush
|
||
/// with the visible terrain mesh and with the player physics path.
|
||
///
|
||
/// <para>
|
||
/// Issue #48 root cause: the previous bilinear fallback in
|
||
/// <c>GameWindow.SampleTerrainZ</c> had its two diagonal arms swapped
|
||
/// (used the SEtoNW triangle test for SWtoNE cells and vice versa),
|
||
/// so on sloped cells scenery sat at a different Z than the visible
|
||
/// terrain by up to ~1.5 m. Routing the fallback through this static
|
||
/// helper guarantees both samplers stay in lock-step.
|
||
/// </para>
|
||
/// </summary>
|
||
public static float SampleZFromHeightmap(
|
||
byte[] heights, float[] heightTable,
|
||
uint landblockX, uint landblockY,
|
||
float localX, float localY)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(heights);
|
||
ArgumentNullException.ThrowIfNull(heightTable);
|
||
if (heights.Length < 81)
|
||
throw new ArgumentException("heights must have 81 entries", nameof(heights));
|
||
if (heightTable.Length < 256)
|
||
throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable));
|
||
|
||
float fx = Math.Clamp(localX / CellSize, 0f, CellsPerSide - 0.001f);
|
||
float fy = Math.Clamp(localY / CellSize, 0f, CellsPerSide - 0.001f);
|
||
int cx = (int)fx;
|
||
int cy = (int)fy;
|
||
cx = Math.Clamp(cx, 0, CellsPerSide - 1);
|
||
cy = Math.Clamp(cy, 0, CellsPerSide - 1);
|
||
|
||
float tx = fx - cx;
|
||
float ty = fy - cy;
|
||
|
||
// x-major heightmap indexing matches TerrainSurface's pre-resolution
|
||
// (heights[x * 9 + y]) and ACE LandblockStruct.
|
||
float hBL = heightTable[heights[cx * HeightmapSide + cy ]];
|
||
float hBR = heightTable[heights[(cx+1) * HeightmapSide + cy ]];
|
||
float hTR = heightTable[heights[(cx+1) * HeightmapSide + (cy+1)]];
|
||
float hTL = heightTable[heights[cx * HeightmapSide + (cy+1)]];
|
||
|
||
bool splitSWtoNE = IsSplitSWtoNE(landblockX, (uint)cx, landblockY, (uint)cy);
|
||
return InterpolateZInTriangle(hBL, hBR, hTR, hTL, tx, ty, splitSWtoNE);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sample the terrain triangle's surface-normal Z component at (localX, localY)
|
||
/// from a raw heightmap. Returns the upward component of the unit normal for
|
||
/// the specific triangle the point lies in — flat ground returns 1.0, steeper
|
||
/// slopes return smaller values. Used by <see cref="SceneryGenerator"/> for
|
||
/// the retail slope filter (<c>CLandCell::find_terrain_poly → polygon.plane.N.z</c>).
|
||
/// </summary>
|
||
public static float SampleNormalZFromHeightmap(
|
||
byte[] heights, float[] heightTable,
|
||
uint landblockX, uint landblockY,
|
||
float localX, float localY)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(heights);
|
||
ArgumentNullException.ThrowIfNull(heightTable);
|
||
if (heights.Length < 81)
|
||
throw new ArgumentException("heights must have 81 entries", nameof(heights));
|
||
if (heightTable.Length < 256)
|
||
throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable));
|
||
|
||
float fx = Math.Clamp(localX / CellSize, 0f, CellsPerSide - 0.001f);
|
||
float fy = Math.Clamp(localY / CellSize, 0f, CellsPerSide - 0.001f);
|
||
int cx = (int)fx;
|
||
int cy = (int)fy;
|
||
cx = Math.Clamp(cx, 0, CellsPerSide - 1);
|
||
cy = Math.Clamp(cy, 0, CellsPerSide - 1);
|
||
|
||
float tx = fx - cx;
|
||
float ty = fy - cy;
|
||
|
||
float hBL = heightTable[heights[cx * HeightmapSide + cy ]];
|
||
float hBR = heightTable[heights[(cx+1) * HeightmapSide + cy ]];
|
||
float hTR = heightTable[heights[(cx+1) * HeightmapSide + (cy+1)]];
|
||
float hTL = heightTable[heights[cx * HeightmapSide + (cy+1)]];
|
||
|
||
bool splitSWtoNE = IsSplitSWtoNE(landblockX, (uint)cx, landblockY, (uint)cy);
|
||
|
||
float dzdx, dzdy;
|
||
if (splitSWtoNE)
|
||
{
|
||
if (tx > ty)
|
||
{
|
||
dzdx = (hBR - hBL) / CellSize;
|
||
dzdy = (hTR - hBR) / CellSize;
|
||
}
|
||
else
|
||
{
|
||
dzdx = (hTR - hTL) / CellSize;
|
||
dzdy = (hTL - hBL) / CellSize;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
if (tx + ty <= 1f)
|
||
{
|
||
dzdx = (hBR - hBL) / CellSize;
|
||
dzdy = (hTL - hBL) / CellSize;
|
||
}
|
||
else
|
||
{
|
||
dzdx = (hTR - hTL) / CellSize;
|
||
dzdy = (hTR - hBR) / CellSize;
|
||
}
|
||
}
|
||
|
||
return 1f / MathF.Sqrt(dzdx * dzdx + dzdy * dzdy + 1f);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Pick the cell's triangle for the chosen diagonal and barycentric-
|
||
/// interpolate Z. Single source of truth shared by both
|
||
/// <see cref="SampleZ"/> (instance, pre-resolved cache) and
|
||
/// <see cref="SampleZFromHeightmap"/> (static, raw heightmap).
|
||
/// Triangle layout matches ACE <c>LandblockStruct.ConstructPolygons</c>:
|
||
/// SWtoNE cells split BL→TR (line y=x), SEtoNW cells split BR→TL
|
||
/// (line x+y=1).
|
||
/// </summary>
|
||
private static float InterpolateZInTriangle(
|
||
float hBL, float hBR, float hTR, float hTL,
|
||
float tx, float ty, bool splitSWtoNE)
|
||
{
|
||
if (splitSWtoNE)
|
||
{
|
||
// Diagonal BL(0,0) → TR(1,1) — line y = x.
|
||
// Triangles: {BL,BR,TR} below (tx > ty), {BL,TR,TL} above.
|
||
if (tx > ty)
|
||
return hBL + (hBR - hBL) * tx + (hTR - hBR) * ty; // BL+BR+TR
|
||
return hBL + (hTR - hTL) * tx + (hTL - hBL) * ty; // BL+TR+TL
|
||
}
|
||
else
|
||
{
|
||
// Diagonal BR(1,0) → TL(0,1) — line x + y = 1.
|
||
// Triangles: {BL,BR,TL} below (tx+ty <= 1), {BR,TR,TL} above.
|
||
if (tx + ty <= 1f)
|
||
return hBL + (hBR - hBL) * tx + (hTL - hBL) * ty; // BL+BR+TL
|
||
return hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty); // BR+TR+TL
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sample both the terrain Z and the triangle-plane surface normal at
|
||
/// (localX, localY). The normal is derived from the gradient of the same
|
||
/// triangle <see cref="SampleZ"/> interpolates across, so returned
|
||
/// <c>(Z, Normal)</c> is exactly the sloped plane the physics should
|
||
/// contact against.
|
||
///
|
||
/// <para>
|
||
/// This matters for <see cref="Transition"/>: when <c>AdjustOffset</c>
|
||
/// projects the per-step movement offset onto the contact plane, a flat
|
||
/// plane (<c>Normal = (0,0,1)</c>) cannot impart any Z component to a
|
||
/// horizontal velocity — the character walks off a slope and the
|
||
/// per-frame step-down budget (~4 cm) can't catch up with the slope
|
||
/// descent rate, so the sphere floats above the terrain. A SLOPED plane
|
||
/// gives AdjustOffset the info it needs to produce slope-aligned motion
|
||
/// with the correct Z component baked in.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Retail does this via <c>LandCell.find_terrain_poly → walkable.Plane</c>
|
||
/// (ACE <c>Landblock.cs:125-137</c>). We derive the equivalent plane
|
||
/// analytically from the chosen triangle's three corner heights.
|
||
/// </para>
|
||
/// </summary>
|
||
public (float Z, System.Numerics.Vector3 Normal) SampleSurface(float localX, float localY)
|
||
{
|
||
float fx = Math.Clamp(localX / CellSize, 0f, CellsPerSide - 0.001f);
|
||
float fy = Math.Clamp(localY / CellSize, 0f, CellsPerSide - 0.001f);
|
||
int cx = (int)fx;
|
||
int cy = (int)fy;
|
||
cx = Math.Clamp(cx, 0, CellsPerSide - 1);
|
||
cy = Math.Clamp(cy, 0, CellsPerSide - 1);
|
||
|
||
float tx = fx - cx;
|
||
float ty = fy - cy;
|
||
|
||
float hBL = _z[cx, cy ];
|
||
float hBR = _z[cx + 1, cy ];
|
||
float hTR = _z[cx + 1, cy + 1];
|
||
float hTL = _z[cx, cy + 1];
|
||
|
||
bool splitSWtoNE = IsSplitSWtoNE(_landblockX, (uint)cx, _landblockY, (uint)cy);
|
||
|
||
// The SampleZ formula for each triangle is linear in (tx, ty):
|
||
// Z = a + b * tx + c * ty
|
||
// so dZ/dLocalX = b / CellSize, dZ/dLocalY = c / CellSize.
|
||
// Surface normal = normalize((-dZ/dX, -dZ/dY, 1)) — a well-known
|
||
// identity for a height-field plane.
|
||
float z, dzdx, dzdy;
|
||
|
||
if (splitSWtoNE)
|
||
{
|
||
// Diagonal BL(0,0) → TR(1,1). Triangles: {BL,BR,TR} / {BL,TR,TL}.
|
||
if (tx > ty)
|
||
{
|
||
// {BL,BR,TR}: Z = hBL + (hBR-hBL)·tx + (hTR-hBR)·ty
|
||
z = hBL + (hBR - hBL) * tx + (hTR - hBR) * ty;
|
||
dzdx = (hBR - hBL) / CellSize;
|
||
dzdy = (hTR - hBR) / CellSize;
|
||
}
|
||
else
|
||
{
|
||
// {BL,TR,TL}: Z = hBL + (hTR-hTL)·tx + (hTL-hBL)·ty
|
||
z = hBL + (hTR - hTL) * tx + (hTL - hBL) * ty;
|
||
dzdx = (hTR - hTL) / CellSize;
|
||
dzdy = (hTL - hBL) / CellSize;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// Diagonal BR(1,0) → TL(0,1). Triangles: {BL,BR,TL} / {BR,TR,TL}.
|
||
if (tx + ty <= 1f)
|
||
{
|
||
// {BL,BR,TL}: Z = hBL + (hBR-hBL)·tx + (hTL-hBL)·ty
|
||
z = hBL + (hBR - hBL) * tx + (hTL - hBL) * ty;
|
||
dzdx = (hBR - hBL) / CellSize;
|
||
dzdy = (hTL - hBL) / CellSize;
|
||
}
|
||
else
|
||
{
|
||
// {BR,TR,TL}: Z = hTR + (hTL-hTR)(1-tx) + (hBR-hTR)(1-ty)
|
||
// Equivalent linear form: Z = [hBR+hTL-hTR] + (hTR-hTL)·tx + (hTR-hBR)·ty
|
||
z = hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty);
|
||
dzdx = (hTR - hTL) / CellSize;
|
||
dzdy = (hTR - hBR) / CellSize;
|
||
}
|
||
}
|
||
|
||
var normal = System.Numerics.Vector3.Normalize(
|
||
new System.Numerics.Vector3(-dzdx, -dzdy, 1f));
|
||
return (z, normal);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sample the terrain triangle at (localX, localY), including the three
|
||
/// local-space vertices that bound the sampled point. Edge-slide needs
|
||
/// these vertices so the retail crossed-edge test can identify which edge
|
||
/// the sphere left when a step-down probe fails.
|
||
/// </summary>
|
||
public TerrainSurfacePolygon SampleSurfacePolygon(float localX, float localY)
|
||
{
|
||
float fx = Math.Clamp(localX / CellSize, 0f, CellsPerSide - 0.001f);
|
||
float fy = Math.Clamp(localY / CellSize, 0f, CellsPerSide - 0.001f);
|
||
int cx = Math.Clamp((int)fx, 0, CellsPerSide - 1);
|
||
int cy = Math.Clamp((int)fy, 0, CellsPerSide - 1);
|
||
|
||
float tx = fx - cx;
|
||
float ty = fy - cy;
|
||
|
||
float hBL = _z[cx, cy ];
|
||
float hBR = _z[cx + 1, cy ];
|
||
float hTR = _z[cx + 1, cy + 1];
|
||
float hTL = _z[cx, cy + 1];
|
||
|
||
bool splitSWtoNE = IsSplitSWtoNE(_landblockX, (uint)cx, _landblockY, (uint)cy);
|
||
|
||
Vector3 bl = new(cx * CellSize, cy * CellSize, hBL);
|
||
Vector3 br = new((cx + 1) * CellSize, cy * CellSize, hBR);
|
||
Vector3 tr = new((cx + 1) * CellSize, (cy + 1) * CellSize, hTR);
|
||
Vector3 tl = new(cx * CellSize, (cy + 1) * CellSize, hTL);
|
||
|
||
float z;
|
||
Vector3[] vertices;
|
||
|
||
if (splitSWtoNE)
|
||
{
|
||
if (tx > ty)
|
||
{
|
||
z = hBL + (hBR - hBL) * tx + (hTR - hBR) * ty;
|
||
vertices = new[] { bl, br, tr };
|
||
}
|
||
else
|
||
{
|
||
z = hBL + (hTR - hTL) * tx + (hTL - hBL) * ty;
|
||
vertices = new[] { bl, tr, tl };
|
||
}
|
||
}
|
||
else
|
||
{
|
||
if (tx + ty <= 1f)
|
||
{
|
||
z = hBL + (hBR - hBL) * tx + (hTL - hBL) * ty;
|
||
vertices = new[] { bl, br, tl };
|
||
}
|
||
else
|
||
{
|
||
z = hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty);
|
||
vertices = new[] { br, tr, tl };
|
||
}
|
||
}
|
||
|
||
var normal = Vector3.Normalize(
|
||
Vector3.Cross(vertices[1] - vertices[0], vertices[2] - vertices[0]));
|
||
if (normal.Z < 0f)
|
||
normal = -normal;
|
||
|
||
return new TerrainSurfacePolygon(z, normal, vertices);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Retail per-point water depth in meters — the amount the character's
|
||
/// feet are allowed to sink below the contact plane before the
|
||
/// push-up fires. <see cref="ValidateWalkable"/> adds this to the
|
||
/// signed-distance check so water cells visually submerge the
|
||
/// character while dry terrain keeps feet exactly on the plane.
|
||
///
|
||
/// <para>
|
||
/// Ported from ACE <c>ObjCell.get_water_depth</c> and
|
||
/// <c>LandblockStruct.calc_water_depth</c>, except that the retail
|
||
/// 0.1 fallback for "dry corner of a partially-water cell" is
|
||
/// collapsed to 0. The 0.1 offset destabilizes the "feet exactly on
|
||
/// plane" contact-touch check (dist > EPSILON, so SetContactPlane
|
||
/// doesn't fire, ValidateTransition clears OnWalkable, gravity
|
||
/// applies, character floats/falls each frame). The visible effect
|
||
/// in retail is subtle (~10 cm natural sink-in) so dropping it to 0
|
||
/// is indistinguishable.
|
||
/// </para>
|
||
/// <list type="bullet">
|
||
/// <item>NotWater cell → <c>0.0f</c></item>
|
||
/// <item>EntirelyWater cell → <c>0.9f</c> (fully submerged)</item>
|
||
/// <item>PartiallyWater cell, nearest-corner water type → <c>0.45f</c></item>
|
||
/// <item>PartiallyWater cell, nearest-corner non-water → <c>0.0f</c>
|
||
/// (retail uses 0.1, we use 0 to avoid the above-EPSILON dist bug)</item>
|
||
/// </list>
|
||
/// </summary>
|
||
public float SampleWaterDepth(float localX, float localY)
|
||
{
|
||
float fx = Math.Clamp(localX / CellSize, 0f, CellsPerSide - 0.001f);
|
||
float fy = Math.Clamp(localY / CellSize, 0f, CellsPerSide - 0.001f);
|
||
int cx = Math.Clamp((int)fx, 0, CellsPerSide - 1);
|
||
int cy = Math.Clamp((int)fy, 0, CellsPerSide - 1);
|
||
|
||
byte waterType = _cellWaterType[cx, cy];
|
||
if (waterType == 0) return 0f; // NotWater
|
||
if (waterType == 2) return 0.9f; // EntirelyWater
|
||
|
||
// PartiallyWater — resolve to nearest corner (retail picks the
|
||
// terrain-vertex at the "+12" half of each axis, i.e. >= 12m into
|
||
// a 24m cell rounds up). Return 0.45 for water corner, 0 for dry
|
||
// (retail uses 0.1 for dry corners; see note above).
|
||
float tx = fx - cx;
|
||
float ty = fy - cy;
|
||
int vx = cx + (tx >= 0.5f ? 1 : 0);
|
||
int vy = cy + (ty >= 0.5f ? 1 : 0);
|
||
|
||
return _cornerIsWater[vx, vy] ? 0.45f : 0f;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Compute the outdoor cell ID for the given landblock-local position.
|
||
/// </summary>
|
||
public uint ComputeOutdoorCellId(float localX, float localY)
|
||
{
|
||
int cx = Math.Clamp((int)(localX / CellSize), 0, CellsPerSide - 1);
|
||
int cy = Math.Clamp((int)(localY / CellSize), 0, CellsPerSide - 1);
|
||
return (uint)(1 + cx * CellsPerSide + cy);
|
||
}
|
||
|
||
/// <summary>
|
||
/// AC2D's FSplitNESW render formula. Returns true for SW→NE diagonal.
|
||
/// Uses global cell coordinates (landblockX*8+cellX, landblockY*8+cellY).
|
||
/// </summary>
|
||
private static bool IsSplitSWtoNE(uint landblockX, uint cellX, uint landblockY, uint cellY)
|
||
{
|
||
uint x = landblockX * 8 + cellX;
|
||
uint y = landblockY * 8 + cellY;
|
||
uint dw = unchecked(x * y * 0x0CCAC033u - x * 0x421BE3BDu + y * 0x6C1AC587u - 0x519B8F25u);
|
||
return (dw & 0x80000000u) != 0;
|
||
}
|
||
}
|