Skip to content

Commit 0abec18

Browse files
henderkesclaude
andcommitted
perf(terrain): compute draped altitude natively instead of per-frame QueryAltitude
Draped in-world overlays (skill range rings, loot beacons, danger rings, quest path, rivers, weather) sampled the surface via GW::Map::QueryAltitude once per plane per vertex every frame. That call swaps the global map context and walks the terrain heightfield, so a large ring (~1000 verts x N planes) collapsed the frame rate (#2408). Read the terrain heightfield directly instead. NativeTerrainZ resolves the exact surface z at a point from the mapped terrain object (MapContext+0x84): a 32x32-float-chunk grid, 96-gwinch tiles, Y-flipped, B-C diagonal split. It is a pure memory read with no game call and no context swap. QueryAltAt now routes plane 0 (the common case) through it, so every draped consumer gets the speedup; non-zero planes still use QueryAltitude for the prop-mesh raycast. SurfaceZ/DrapeZ no longer scan all planes. They take the native plane-0 surface as the baseline and query only the non-zero planes whose pathing trapezoid actually contains (x,y), located via a point-location DAG walk. Verified in-game (map 474, 42 planes): native z matches the client's own plane-0 value within the expected radius-disk-vs-point delta; the pruned SurfaceZ is 12.5x faster than the old all-planes loop (21.4 -> 1.7 us/query); a hovered Sandstorm (312 + 1050 rings) costs ~1 fps instead of collapsing it. Struct layouts and query math reverse-engineered from the client; see the harness verbs nativez / drapebench / fpsprobe (Debug-only) used to verify. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013qkJXMrytQ75KYP4o2yZgX
1 parent 25bea3f commit 0abec18

3 files changed

Lines changed: 310 additions & 13 deletions

File tree

GWToolboxdll/Modules/TestHarness.cpp

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#include "stdafx.h"
22

3+
#include <chrono>
34
#include <cmath>
45
#include <filesystem>
56
#include <sstream>
@@ -71,6 +72,11 @@ namespace {
7172
bool log_play_effects = false;
7273
GW::HookEntry PlayEffect_Entry;
7374

75+
bool fps_active = false;
76+
int fps_frames = 0;
77+
clock_t fps_start = 0;
78+
long fps_duration_ms = 0;
79+
7480
std::filesystem::path cmd_path() { return Resources::GetPath(L"harness_command.txt"); }
7581
std::filesystem::path status_path() { return Resources::GetPath(L"harness_status.txt"); }
7682
std::filesystem::path config_path() { return Resources::GetPath(L"harness_config.txt"); }
@@ -521,6 +527,110 @@ namespace {
521527
write_status("dattex: queued");
522528
return;
523529
}
530+
if (verb == "nativez") { // nativez [radius step]: compare native terrain z vs GW::Map::QueryAltitude(plane 0) on a grid
531+
float radius = 1200.f, step = 96.f;
532+
is >> radius >> step;
533+
radius = std::clamp(radius, 100.f, 5000.f);
534+
step = std::clamp(step, 24.f, 500.f);
535+
const auto self = GW::Agents::GetControlledCharacter();
536+
if (!self) { write_status("nativez: no character"); return; }
537+
Log::Log("[nativez] map=%d player=(%.0f,%.0f,z%u) radius=%.0f step=%.0f",
538+
static_cast<int>(GW::Map::GetMapID()), self->pos.x, self->pos.y, self->pos.zplane, radius, step);
539+
uint32_t samples = 0, both = 0, logged = 0;
540+
float max_delta = 0.f;
541+
double sum_delta = 0.0;
542+
for (float dx = -radius; dx <= radius; dx += step) {
543+
for (float dy = -radius; dy <= radius; dy += step) {
544+
const float x = self->pos.x + dx, y = self->pos.y + dy;
545+
const float native = TerrainDrape::NativeTerrainZ(x, y);
546+
GW::GamePos p(x, y, 0);
547+
const float game = GW::Map::QueryAltitude(&p);
548+
++samples;
549+
if (native == 0.f || game == 0.f) continue; // OOB on either side
550+
++both;
551+
const float d = std::fabs(native - game);
552+
max_delta = std::max(max_delta, d);
553+
sum_delta += d;
554+
if (d > 5.f && logged++ < 20)
555+
Log::Log("[nativez] (%.0f,%.0f) native=%.1f game=%.1f delta=%.1f", x, y, native, game, d);
556+
}
557+
}
558+
char b[160];
559+
snprintf(b, sizeof(b), "nativez: samples=%u both=%u max_delta=%.2f avg_delta=%.3f", samples, both, max_delta,
560+
both ? sum_delta / both : 0.0);
561+
Log::Log("[harness] %s", b);
562+
Log::FlushFile();
563+
write_status(b);
564+
return;
565+
}
566+
if (verb == "drapebench") { // drapebench [n radius]: A/B the old all-planes QueryAltitude loop vs the new pruned SurfaceZ
567+
uint32_t n = 2000;
568+
float radius = 1200.f;
569+
is >> n >> radius;
570+
n = std::clamp(n, 100u, 200000u);
571+
radius = std::clamp(radius, 100.f, 5000.f);
572+
const auto self = GW::Agents::GetControlledCharacter();
573+
const auto* pm = GW::Map::GetPathingMap();
574+
if (!self || !pm || !pm->size()) {
575+
write_status("drapebench: no character or pathing map");
576+
return;
577+
}
578+
const auto n_planes = static_cast<uint32_t>(pm->size());
579+
// Same pseudo-random disk of sample points for both methods.
580+
std::vector<std::pair<float, float>> pts(n);
581+
uint32_t seed = 0x9E3779B9u;
582+
const auto next01 = [&seed] {
583+
seed = seed * 1664525u + 1013904223u;
584+
return (seed >> 8) * (1.f / 16777216.f);
585+
};
586+
for (uint32_t i = 0; i < n; ++i) {
587+
const float ang = next01() * 6.2831853f, r = radius * std::sqrt(next01());
588+
pts[i] = {self->pos.x + std::cos(ang) * r, self->pos.y + std::sin(ang) * r};
589+
}
590+
591+
// OLD path: QueryAltitude on every plane, keep highest surface (min z). This is what SurfaceZ did before.
592+
float sink_old = 0.f;
593+
const auto t_old0 = std::chrono::steady_clock::now();
594+
for (uint32_t i = 0; i < n; ++i) {
595+
float best = 0.f;
596+
for (uint32_t zp = 0; zp < n_planes; ++zp) {
597+
GW::GamePos p(pts[i].first, pts[i].second, zp);
598+
const float a = GW::Map::QueryAltitude(&p);
599+
if (a != 0.f && (best == 0.f || a < best)) best = a;
600+
}
601+
sink_old += best;
602+
}
603+
const auto us_old = std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::steady_clock::now() - t_old0).count();
604+
605+
// NEW path: native plane-0 read + trapezoid-pruned non-zero planes.
606+
float sink_new = 0.f;
607+
const auto t_new0 = std::chrono::steady_clock::now();
608+
for (uint32_t i = 0; i < n; ++i)
609+
sink_new += TerrainDrape::SurfaceZ(pts[i].first, pts[i].second, self->pos.zplane, n_planes);
610+
const auto us_new = std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::steady_clock::now() - t_new0).count();
611+
612+
char b[220];
613+
snprintf(b, sizeof(b),
614+
"drapebench: n=%u planes=%u | OLD %.2fms (%.2fus/q) | NEW %.2fms (%.2fus/q) | speedup %.1fx | sink d=%.0f",
615+
n, n_planes, us_old / 1000.0, static_cast<double>(us_old) / n, us_new / 1000.0,
616+
static_cast<double>(us_new) / n, us_new ? static_cast<double>(us_old) / us_new : 0.0,
617+
std::fabs(sink_old - sink_new));
618+
Log::Log("[harness] %s", b);
619+
Log::FlushFile();
620+
write_status(b);
621+
return;
622+
}
623+
if (verb == "fpsprobe") { // fpsprobe [seconds]: count frames over a window, report avg fps
624+
float seconds = 3.f;
625+
is >> seconds;
626+
seconds = std::clamp(seconds, 0.5f, 30.f);
627+
fps_duration_ms = static_cast<long>(seconds * 1000.f);
628+
fps_frames = 0;
629+
fps_start = TIMER_INIT();
630+
fps_active = true;
631+
write_status("fpsprobe: running");
632+
return;
633+
}
524634
write_status("unknown_command: " + verb);
525635
}
526636
} // namespace
@@ -555,6 +665,18 @@ void TestHarness::Update(float)
555665
{
556666
#ifdef HARNESS_ENABLED
557667
if (terminating) return;
668+
if (fps_active) {
669+
++fps_frames;
670+
const long elapsed = TIMER_DIFF(fps_start);
671+
if (elapsed >= fps_duration_ms) {
672+
fps_active = false;
673+
char b[96];
674+
snprintf(b, sizeof(b), "fpsprobe: frames=%d secs=%.2f avg_fps=%.1f", fps_frames, elapsed / 1000.f, fps_frames * 1000.f / elapsed);
675+
Log::Log("[harness] %s", b);
676+
Log::FlushFile();
677+
write_status(b);
678+
}
679+
}
558680
if (last_poll && TIMER_DIFF(last_poll) < kPollMs) return;
559681
last_poll = TIMER_INIT();
560682

GWToolboxdll/Utils/TerrainDrape.cpp

Lines changed: 177 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,204 @@
11
#include "stdafx.h"
22

3+
#include <cfloat>
4+
#include <cmath>
5+
#include <cstddef>
6+
37
#include <GWCA/GameContainers/GamePos.h>
48
#include <GWCA/GameEntities/Pathing.h>
9+
#include <GWCA/Context/MapContext.h>
510
#include <GWCA/Managers/MapMgr.h>
611

712
#include <Utils/TerrainDrape.h>
813

14+
namespace {
15+
// GW's terrain heightfield object (MapContext+0x84). Only the fields the altitude sampler touches
16+
// are named; layout reverse-engineered from Terrain_QueryAltitude (Gw.exe FUN_0074eb10). See
17+
// memory reference-gw-terrain-altitude-internals for the full derivation.
18+
struct GwTerrain {
19+
uint32_t type; // +0x00
20+
uint32_t dim_x; // +0x04 tiles in x (multiple of 32)
21+
uint32_t dim_y; // +0x08 tiles in y
22+
uint32_t chunks_x; // +0x0C dim_x >> 5
23+
uint32_t chunks_y; // +0x10 dim_y >> 5
24+
uint32_t flags; // +0x14 bit0: clamp z>0 result to 0
25+
float min_x; // +0x18
26+
float min_y; // +0x1C
27+
float max_x; // +0x20
28+
float max_y; // +0x24 grid anchored at (min_x, max_y)
29+
uint32_t pad0[2]; // +0x28
30+
uint32_t grid_dim_x; // +0x30
31+
uint32_t grid_dim_y; // +0x34
32+
uint32_t chunks_per_row; // +0x38 (dim_x >> 5) + 1 (the +1 is a border chunk column)
33+
uint32_t chunk_rows; // +0x3C (dim_y >> 5) + 1
34+
float** chunk_ptrs; // +0x40 chunks_per_row * chunk_rows entries -> 32x32 float32
35+
uint32_t chunk_count; // +0x44
36+
uint32_t chunk_capacity; // +0x48
37+
};
38+
static_assert(offsetof(GwTerrain, dim_x) == 0x04);
39+
static_assert(offsetof(GwTerrain, flags) == 0x14);
40+
static_assert(offsetof(GwTerrain, min_x) == 0x18);
41+
static_assert(offsetof(GwTerrain, max_y) == 0x24);
42+
static_assert(offsetof(GwTerrain, chunks_per_row) == 0x38);
43+
static_assert(offsetof(GwTerrain, chunk_ptrs) == 0x40);
44+
static_assert(offsetof(GwTerrain, chunk_capacity) == 0x48);
45+
46+
constexpr float kTile = 96.0f;
47+
constexpr float kNoData = FLT_MAX; // internal "no surface" sentinel; mapped to GW's 0.f at the API edge
48+
49+
const GwTerrain* GetTerrain()
50+
{
51+
const GW::MapContext* mc = GW::GetMapContext();
52+
if (!mc || !mc->terrain) return nullptr;
53+
const auto* t = static_cast<const GwTerrain*>(mc->terrain);
54+
// Cheap sanity gate against a torn-down / mid-load terrain object.
55+
if (t->dim_x == 0 || t->dim_x > 0x8000 || t->dim_y == 0 || t->dim_y > 0x8000) return nullptr;
56+
if (t->chunks_per_row == 0 || !t->chunk_ptrs) return nullptr;
57+
return t;
58+
}
59+
60+
float SampleGrid(const GwTerrain* t, const uint32_t gx, const uint32_t gy)
61+
{
62+
const uint32_t ci = (gy >> 5) * t->chunks_per_row + (gx >> 5);
63+
if (ci >= t->chunk_capacity) return kNoData;
64+
const float* chunk = t->chunk_ptrs[ci];
65+
if (!chunk) return kNoData;
66+
return chunk[(gy & 31) * 32 + (gx & 31)];
67+
}
68+
69+
// cross2d(P,A,B) = (P.y-A.y)(B.x-A.x) - (P.x-A.x)(B.y-A.y); the exact side test GW uses (FUN_007294e0).
70+
float Cross2D(const float px, const float py, const float ax, const float ay, const float bx, const float by)
71+
{
72+
return (py - ay) * (bx - ax) - (px - ax) * (by - ay);
73+
}
74+
75+
// GW's strict point-in-trapezoid test (FUN_0072b680): y-band open, left edge >= 0, right edge <= 0.
76+
bool TrapezoidContains(const GW::PathingTrapezoid* pt, const float x, const float y)
77+
{
78+
if (!(pt->YB < y && y < pt->YT)) return false;
79+
if (Cross2D(x, y, pt->XTL, pt->YT, pt->XBL, pt->YB) < 0.f) return false;
80+
if (Cross2D(x, y, pt->XTR, pt->YT, pt->XBR, pt->YB) > 0.f) return false;
81+
return true;
82+
}
83+
84+
// Walk one plane's point-location DAG (root at PathingMap::root_node) to the containing trapezoid.
85+
// Returns true iff a real trapezoid of this plane covers (x,y). Mirrors GW's exact locator
86+
// (FUN_0072aa80); the toolbox's older Pathing::FindTrapezoid has an inverted entry guard, so this is
87+
// implemented fresh rather than reused.
88+
bool PlaneContains(const GW::PathingMap& pm, const float x, const float y)
89+
{
90+
const GW::Node* n = pm.root_node;
91+
int guard = 100000;
92+
while (n && guard-- > 0) {
93+
switch (n->type) {
94+
case 0: { // XNode
95+
const auto* xn = static_cast<const GW::XNode*>(n);
96+
const float d = (y - xn->pos.y) * xn->dir.x - (x - xn->pos.x) * xn->dir.y;
97+
n = d >= 0.f ? xn->right : xn->left;
98+
break;
99+
}
100+
case 1: { // YNode
101+
const auto* yn = static_cast<const GW::YNode*>(n);
102+
n = (y > yn->pos.y) ? yn->above
103+
: (y < yn->pos.y) ? yn->below
104+
: (x >= yn->pos.x) ? yn->above : yn->below;
105+
break;
106+
}
107+
case 2: { // SinkNode -- the leaf's trapezoid contains (x,y) only if the point is really on it
108+
const auto* sn = static_cast<const GW::SinkNode*>(n);
109+
return sn->trapezoid && TrapezoidContains(sn->trapezoid, x, y);
110+
}
111+
default:
112+
return false;
113+
}
114+
}
115+
return false;
116+
}
117+
}
118+
119+
float TerrainDrape::NativeTerrainZ(const float x, const float y)
120+
{
121+
const GwTerrain* t = GetTerrain();
122+
if (!t) return 0.f;
123+
124+
const float gxf = (x - t->min_x) / kTile;
125+
const float gyf = (t->max_y - y) / kTile; // grid rows grow toward -y
126+
if (gxf < 0.f || gyf < 0.f) return 0.f;
127+
128+
const auto gxi = static_cast<uint32_t>(gxf);
129+
const auto gyi = static_cast<uint32_t>(gyf);
130+
if (gxi >= t->dim_x || gyi >= t->dim_y) return 0.f; // gxi+1 <= dim_x reads the border chunk column
131+
132+
const float fx = gxf - static_cast<float>(gxi);
133+
const float fy = gyf - static_cast<float>(gyi);
134+
135+
const float zA = SampleGrid(t, gxi, gyi);
136+
const float zB = SampleGrid(t, gxi + 1, gyi);
137+
const float zC = SampleGrid(t, gxi, gyi + 1);
138+
const float zD = SampleGrid(t, gxi + 1, gyi + 1);
139+
if (zA == kNoData || zB == kNoData || zC == kNoData || zD == kNoData) return 0.f;
140+
141+
// Cell is split on the B-C diagonal (fx+fy==1), matching the game's triangulation.
142+
float z;
143+
if (fx + fy <= 1.f)
144+
z = zA + fx * (zB - zA) + fy * (zC - zA);
145+
else
146+
z = zB + (fx + fy - 1.f) * (zD - zB) + (1.f - fx) * (zC - zB);
147+
148+
if ((t->flags & 1u) && z > 0.f) z = 0.f; // GW clamps below-zero-plane surfaces
149+
return z;
150+
}
151+
9152
float TerrainDrape::QueryAltAt(const float x, const float y, const uint32_t zplane)
10153
{
154+
if (zplane == 0)
155+
return NativeTerrainZ(x, y);
11156
GW::GamePos p{x, y, zplane};
12157
return GW::Map::QueryAltitude(&p);
13158
}
14159

15160
float TerrainDrape::DrapeZ(const float x, const float y, const uint32_t zplane, const uint32_t n_planes, const float ref)
16161
{
17162
float best = ref, best_d = FLT_MAX;
18-
for (uint32_t zp = 0; zp < n_planes; ++zp) {
19-
const float a = QueryAltAt(x, y, zp);
20-
if (a == 0.f) continue;
21-
const float d = std::fabs(a - ref);
22-
if (d < best_d || (d == best_d && zp == zplane)) {
23-
best_d = d;
24-
best = a;
163+
164+
const float ground = NativeTerrainZ(x, y);
165+
if (ground != 0.f) {
166+
best_d = std::fabs(ground - ref);
167+
best = ground;
168+
}
169+
170+
const GW::PathingMapArray* pm = GW::Map::GetPathingMap();
171+
if (pm) {
172+
const auto planes = std::min<uint32_t>(n_planes, static_cast<uint32_t>(pm->size()));
173+
for (uint32_t zp = 1; zp < planes; ++zp) {
174+
if (!PlaneContains((*pm)[zp], x, y)) continue;
175+
const float a = QueryAltAt(x, y, zp);
176+
if (a == 0.f) continue;
177+
const float d = std::fabs(a - ref);
178+
if (d < best_d || (d == best_d && zp == zplane)) {
179+
best_d = d;
180+
best = a;
181+
}
25182
}
26183
}
27184
return best;
28185
}
29186

30187
float TerrainDrape::SurfaceZ(const float x, const float y, const uint32_t, const uint32_t n_planes)
31188
{
32-
float best = 0.f;
33-
for (uint32_t zp = 0; zp < n_planes; ++zp) {
34-
const float a = QueryAltAt(x, y, zp);
35-
if (a != 0.f && (best == 0.f || a < best)) best = a;
189+
// Highest surface = min z. Plane-0 ground is a native read; only planes whose trapezoid actually
190+
// covers (x,y) can add a higher (bridge/prop) surface, so the old blind all-planes loop is pruned
191+
// to those candidates.
192+
float best = NativeTerrainZ(x, y); // 0.f when off-terrain
193+
194+
const GW::PathingMapArray* pm = GW::Map::GetPathingMap();
195+
if (pm) {
196+
const auto planes = std::min<uint32_t>(n_planes, static_cast<uint32_t>(pm->size()));
197+
for (uint32_t zp = 1; zp < planes; ++zp) {
198+
if (!PlaneContains((*pm)[zp], x, y)) continue;
199+
const float a = QueryAltAt(x, y, zp);
200+
if (a != 0.f && (best == 0.f || a < best)) best = a;
201+
}
36202
}
37203
return best;
38204
}

GWToolboxdll/Utils/TerrainDrape.h

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,18 @@
33
#include <cstdint>
44

55
// Surface-draping helpers shared by the in-world overlay modules (danger rings, loot beacons,
6-
// skill range rings). QueryAltitude swaps the global map context, so call these ONLY on the
7-
// render thread. GW's "no altitude data" sentinel is 0.f, and up is -z (highest surface = min z).
6+
// skill range rings). GW's "no altitude data" sentinel is 0.f, and up is -z (highest surface = min z).
7+
//
8+
// Plane 0 (the terrain heightfield) is read directly from the mapped terrain object -- no game call,
9+
// no map-context swap -- so it is safe from any thread and cheap enough to sample per vertex per frame.
10+
// Non-zero planes (bridges/prop surfaces) still route through GW::Map::QueryAltitude, which swaps the
11+
// global map context, so the DrapeZ/SurfaceZ helpers must run ONLY on the render thread. They prune the
12+
// old all-planes loop to just the planes whose pathing trapezoid actually contains (x,y).
813
namespace TerrainDrape {
14+
// Exact terrain surface z at (x,y) on plane 0, interpolated from the heightfield. Returns 0.f when
15+
// (x,y) is outside the terrain or the map is not loaded (matching GW's OOB sentinel). Native read.
16+
float NativeTerrainZ(float x, float y);
17+
918
float QueryAltAt(float x, float y, uint32_t zplane);
1019

1120
// Surface altitude at (x,y), choosing the plane closest to `ref`.

0 commit comments

Comments
 (0)