fix(B.7): scale indicator box from projected entity height, not fixed pixels

Visual test surfaced two B.7 MVP issues:

1. Box anchored at abdomen + fixed 48px size meant the rectangle
   shrank visually as the camera approached the entity (entity got
   bigger on screen, box stayed 48px → triangles ended up inside
   the silhouette).
2. Origin was a single point (entity position + 0.9m WorldVerticalOffset)
   so the box wasn't centred on the visible body.

Fix: project both feet (WorldPosition) and head (WorldPosition.Z +
EntityHeight=1.8m) to screen space. Apparent pixel height between the
two = box height; halve it for width (WidthHeightRatio=0.5 ≈
humanoid). Box centred at midpoint of projected feet+head.

  - Closer entity → bigger projected height → bigger box. Distance
    scaling is automatic from the perspective projection.
  - Farther entity → smaller projected height → MinScreenHeight=16px
    floor prevents the box collapsing to a point.
  - Box is screen-axis-aligned (always rectangular on screen) but
    sized + positioned by the entity's actual world-space silhouette.

Properties exposed (TriangleSize, EntityHeight, WidthHeightRatio,
MinScreenHeight) so the panel can be tuned per-instance if a future
caller wants short-item boxes (drop EntityHeight to ~0.3m for tapers,
keep WidthHeightRatio at 1.0 for a square box).

Stuck-on-+Je issue (clicking other things still returns +Je) is
Issue #59 — picker over-pick — and unaffected by this commit.
This commit is contained in:
Erik 2026-05-15 07:02:35 +02:00
parent c7e5f9f00f
commit 4bc95eca01

View file

@ -48,25 +48,33 @@ public sealed class TargetIndicatorPanel
private readonly Func<uint, TargetInfo?> _entityResolver;
private readonly Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> _cameraProvider;
/// <summary>
/// Pixel half-extent of the bounding square. Triangles sit at the
/// four corners of a 2 × HalfExtent square centred on the entity.
/// Future: scale by apparent radius (project entity bounds + read
/// pixel size) instead of a fixed value.
/// </summary>
public float HalfExtent { get; set; } = 24f;
/// <summary>
/// Pixel size of each corner triangle's right-angle legs.
/// </summary>
public float TriangleSize { get; set; } = 8f;
public float TriangleSize { get; set; } = 10f;
/// <summary>
/// World-space vertical offset to place the indicator box around
/// the entity's torso/body rather than its feet. 0.9 m matches
/// rough humanoid mid-body height.
/// World-space height of the indicator box. The panel projects feet
/// (at <c>WorldPosition</c>) and head (at <c>WorldPosition.Z +
/// EntityHeight</c>) to screen space and uses the projected pixel
/// distance as the box height. 1.8 m matches a standing humanoid;
/// short items still get a small box because the projection
/// preserves apparent size.
/// </summary>
public float WorldVerticalOffset { get; set; } = 0.9f;
public float EntityHeight { get; set; } = 1.8f;
/// <summary>
/// Box width = <see cref="EntityHeight"/> projected height ×
/// <see cref="WidthHeightRatio"/>. Humanoids are about half as
/// wide as tall on screen, so 0.5 is a sensible default.
/// </summary>
public float WidthHeightRatio { get; set; } = 0.5f;
/// <summary>
/// Floor for the projected screen height (pixels). Prevents the
/// indicator from collapsing to a point on far-away entities.
/// </summary>
public float MinScreenHeight { get; set; } = 16f;
public TargetIndicatorPanel(
Func<uint?> selectedGuidProvider,
@ -92,36 +100,46 @@ public sealed class TargetIndicatorPanel
var (view, projection, viewport) = _cameraProvider();
if (viewport.X <= 0 || viewport.Y <= 0) return;
// Project world position (lifted to mid-body height) to clip space.
var worldPos = new Vector4(
var viewProj = view * projection;
// Project the entity's feet and head to screen space. Apparent
// pixel height (= |headY - feetY|) gives the box height; halve
// it for width. Closer entity → bigger projected height → bigger
// box. Distance scaling is automatic from the perspective
// projection.
if (!TryProjectToScreen(info.WorldPosition, viewProj, viewport, out var feetScreen))
return;
var headWorld = new Vector3(
info.WorldPosition.X,
info.WorldPosition.Y,
info.WorldPosition.Z + WorldVerticalOffset,
1f);
var clip = Vector4.Transform(worldPos, view * projection);
if (clip.W <= 0.001f) return; // behind / on the near plane
info.WorldPosition.Z + EntityHeight);
if (!TryProjectToScreen(headWorld, viewProj, viewport, out var headScreen))
return;
// NDC and viewport conversion.
float ndcX = clip.X / clip.W;
float ndcY = clip.Y / clip.W;
if (ndcX < -1f || ndcX > 1f || ndcY < -1f || ndcY > 1f)
return; // off-screen — MVP doesn't render the edge arrow.
// Apparent height + width.
float screenHeight = MathF.Abs(headScreen.Y - feetScreen.Y);
if (screenHeight < MinScreenHeight) screenHeight = MinScreenHeight;
float screenWidth = screenHeight * WidthHeightRatio;
// GL NDC Y points UP; screen Y points DOWN. Flip Y.
float screenX = (ndcX * 0.5f + 0.5f) * viewport.X;
float screenY = (1f - (ndcY * 0.5f + 0.5f)) * viewport.Y;
// Box center = midpoint of feet/head projection. Use the X from
// the head projection only (feet may project to a slightly
// different X if the camera looks down at an angle; midpoint is
// a stable centre regardless).
Vector2 center = (feetScreen + headScreen) * 0.5f;
float halfW = screenWidth * 0.5f;
float halfH = screenHeight * 0.5f;
Vector2 tl = new(center.X - halfW, center.Y - halfH);
Vector2 tr = new(center.X + halfW, center.Y - halfH);
Vector2 br = new(center.X + halfW, center.Y + halfH);
Vector2 bl = new(center.X - halfW, center.Y + halfH);
var rgba = RadarBlipColors.For(info.ItemType, info.ObjectDescriptionFlags);
uint col = MakeImGuiColor(rgba);
var drawList = ImGui.GetBackgroundDrawList();
// Four corners of the bounding square.
Vector2 tl = new(screenX - HalfExtent, screenY - HalfExtent);
Vector2 tr = new(screenX + HalfExtent, screenY - HalfExtent);
Vector2 br = new(screenX + HalfExtent, screenY + HalfExtent);
Vector2 bl = new(screenX - HalfExtent, screenY + HalfExtent);
float t = TriangleSize;
// Each corner triangle: right-angle at the corner itself, legs
@ -134,6 +152,38 @@ public sealed class TargetIndicatorPanel
drawList.AddTriangleFilled(bl, bl + new Vector2( t, 0), bl + new Vector2(0, -t), col);
}
/// <summary>
/// Project a world-space point to screen-space pixels. Returns
/// <c>false</c> if the point is behind the camera or outside the
/// expanded viewport (±20 % margin so a tall entity whose feet are
/// just off the bottom of the screen still gets its head projected).
/// </summary>
private static bool TryProjectToScreen(
Vector3 world,
Matrix4x4 viewProj,
Vector2 viewport,
out Vector2 screen)
{
var clip = Vector4.Transform(new Vector4(world, 1f), viewProj);
if (clip.W <= 0.001f)
{
screen = Vector2.Zero;
return false;
}
float ndcX = clip.X / clip.W;
float ndcY = clip.Y / clip.W;
const float margin = 1.2f;
if (ndcX < -margin || ndcX > margin || ndcY < -margin || ndcY > margin)
{
screen = Vector2.Zero;
return false;
}
screen = new Vector2(
(ndcX * 0.5f + 0.5f) * viewport.X,
(1f - (ndcY * 0.5f + 0.5f)) * viewport.Y);
return true;
}
private static uint MakeImGuiColor(RadarBlipColors.Rgba c)
{
// ImGui packed colour is 0xAABBGGRR (little-endian RGBA).