forked from intel/gvk
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.cpp
More file actions
2219 lines (1948 loc) · 127 KB
/
Copy pathmain.cpp
File metadata and controls
2219 lines (1948 loc) · 127 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#define IMGUI_DEFINE_MATH_OPERATORS
#include <imgui.h>
#include <imgui_impl_sdl2.h>
#include <imgui_impl_opengl3.h>
#include <iostream>
#include <SDL.h>
#if defined(IMGUI_IMPL_OPENGL_ES2)
#include <SDL_opengles2.h>
#else
#include <SDL_opengl.h>
#endif
#ifndef GL_FRAMEBUFFER
#define GL_FRAMEBUFFER 0x8D40
#define GL_COLOR_ATTACHMENT0 0x8CE0
#endif
#include <stdio.h>
#include <vector>
#include <limits>
#include <algorithm>
#include <chrono>
#include <string>
#include <cstdlib> // atoi
#include "TileCache.h"
#include "nodes.h" //Also includes DataMOdel.h and LayoutEngine.h
#include "GLFunctions.h"
#include "GPURenderer.h"
#include "PhysicsLayoutSimulation.h"
#include "Debug.h"
// Verbose debug output (prints, profiling, layout-engine detail dumps). Off unless --debug is passed.
bool gDebugEnabled = false;
#ifdef _WIN32
#include <psapi.h>
#pragma comment(lib, "psapi.lib")
// Request the discrete GPU on hybrid-graphics laptops - Windows otherwise defaults an unrecognized
// .exe to the iGPU, whose Intel driver gives a strict GL 3.0 context and no compute shaders. Both
// vendors' drivers look these up by name at process start, so they must stay exported.
// (DWORD spelled as unsigned long so this doesn't depend on windows.h being included first.)
extern "C" {
__declspec(dllexport) unsigned long NvOptimusEnablement = 0x00000001;
__declspec(dllexport) int AmdPowerXpressRequestHighPerformance = 1;
}
#endif
// Calculate bounding box of all nodes
ImVec2 CalculateNodesBounds(const ImGui::GraphView& graphView, ImVec2& outMin, ImVec2& outMax)
{
outMin = ImVec2(std::numeric_limits<float>::max(), std::numeric_limits<float>::max());
outMax = ImVec2(std::numeric_limits<float>::lowest(), std::numeric_limits<float>::lowest());
for (const std::pair<const int, ImGui::NodeView*> pair : graphView.nodeViewCache_)
{
const ImGui::NodeView* node = pair.second;
outMin.x = ImMin(outMin.x, node->area_node_.Min.x);
outMin.y = ImMin(outMin.y, node->area_node_.Min.y);
outMax.x = ImMax(outMax.x, node->area_node_.Max.x);
outMax.y = ImMax(outMax.y, node->area_node_.Max.y);
}
return ImVec2(outMax.x - outMin.x, outMax.y - outMin.y); // Return size
}
// Get system RAM in GB (total)
float GetSystemRAM_GB() {
#ifdef _WIN32
MEMORYSTATUSEX memInfo;
memInfo.dwLength = sizeof(MEMORYSTATUSEX);
GlobalMemoryStatusEx(&memInfo);
return (float)memInfo.ullTotalPhys / (1024.0f * 1024.0f * 1024.0f);
#else
return 16.0f; // Conservative default for Linux/Mac
#endif
}
// Get total used physical RAM in GB (by all processes)
float GetUsedPhysicalRAM_GB() {
#ifdef _WIN32
MEMORYSTATUSEX memInfo;
memInfo.dwLength = sizeof(MEMORYSTATUSEX);
GlobalMemoryStatusEx(&memInfo);
// Total - Available = Used by all processes (no paging lies)
DWORDLONG usedBytes = memInfo.ullTotalPhys - memInfo.ullAvailPhys;
return (float)usedBytes / (1024.0f * 1024.0f * 1024.0f);
#else
return 8.0f; // Conservative default for Linux/Mac
#endif
}
// Commit headroom (pagefile-backed) in GB, i.e. ullAvailPageFile.
//
// Distinct from free physical RAM and worth tracking separately: Windows charges a tile's FULL byte
// count against the commit limit the moment it is allocated, whether or not those pages are ever
// resident. Physical RAM can look comfortable while commit is nearly gone - measured here at 146 tiles
// (18.25 GB of tile bytes): working set 11.9 GB but private/commit 21.4 GB. Commit exhaustion is what
// fails allocations outright, so it is the constraint behind the silent crashes at high node counts,
// where the tile set plus the node/edge structures outran commit while free RAM still read fine.
//
// IMPORTANT: this is an instantaneous reading and NOT a ceiling. With a system-managed pagefile Windows
// grows the pagefile on demand, which RAISES the commit limit, so a small reading here does not mean an
// allocation would fail - it means the OS would expand backing store to satisfy it. Budget against
// GetEffectiveCommit_GB() instead, which adds that growth room. Using this value directly as a limit
// throttled the tile cache far below what the machine could run (see kDiskReserveGB).
float GetAvailableCommit_GB() {
#ifdef _WIN32
MEMORYSTATUSEX memInfo;
memInfo.dwLength = sizeof(MEMORYSTATUSEX);
GlobalMemoryStatusEx(&memInfo);
return (float)memInfo.ullAvailPageFile / (1024.0f * 1024.0f * 1024.0f);
#else
return 8.0f; // Conservative default for Linux/Mac
#endif
}
// Disk space left untouched when counting pagefile growth room. The pagefile can in principle grow until
// the volume is full; letting the tile budget bank on that would trade a graph nobody asked for against
// a wedged OS, so a fixed slice of the disk is never counted as commit.
static const float kDiskReserveGB = 8.0f;
// Free space on the volume holding the pagefile, i.e. how far the commit limit can still grow.
float GetPagefileVolumeFree_GB() {
#ifdef _WIN32
ULARGE_INTEGER freeBytesAvailable;
// The system pagefile lives on the Windows volume; GetWindowsDirectory gives us that root without
// assuming a drive letter.
char winDir[MAX_PATH] = {0};
if (!GetWindowsDirectoryA(winDir, MAX_PATH)) return 0.0f;
char root[4] = { winDir[0], ':', '\\', '\0' };
if (!GetDiskFreeSpaceExA(root, &freeBytesAvailable, NULL, NULL)) return 0.0f;
return (float)freeBytesAvailable.QuadPart / (1024.0f * 1024.0f * 1024.0f);
#else
return 0.0f;
#endif
}
// Commit we can actually count on: what is free now, PLUS what the pagefile can still grow into.
//
// This is the correction for the tile cache being throttled below its predecessor. Budgeting against the
// instantaneous available commit alone was wrong twice over on a machine with a small auto-managed
// pagefile: measured 20.3 GB free commit but only 0.77 GB of a 7.5 GB pagefile actually in use (peak
// 1.5 GB), with 18 GB of free disk behind it. The commit limit was nowhere near its real ceiling, yet
// the check reported ~3.3 GB headroom against a 3.2 GB margin and pinned the cap at once - so a layout
// that used to render stopped completing.
//
// Counting growth room makes physical RAM the binding limit again, which is the intended design: RAM is
// the resource whose exhaustion actually kills the process, and it is the one the safety margin guards.
float GetEffectiveCommit_GB() {
float growthRoom = GetPagefileVolumeFree_GB() - kDiskReserveGB;
if (growthRoom < 0.0f) growthRoom = 0.0f;
return GetAvailableCommit_GB() + growthRoom;
}
float TilePhysicalCostGB(); // defined with the tile budget model below
float TileVRAMCreditGB(); // ditto - tile bytes the card absorbs, applied once to the whole set
// How far the tile set's commit may exceed physical RAM, i.e. how much of it may be pagefile-backed by
// design. Zero: the set is sized to fit in RAM, leaving the pagefile as spillover Windows may use rather
// than budget spent in advance. Raising it buys tiles (~+35k grid nodes per 2 GB on a 32 GB machine) but not
// tiles you can rely on: Windows picks what gets paged out, so a paged tile makes a pan block on disk I/O.
// Set by --commit-overrun; see PrintUsage for the user-facing warning.
float gCommitOverrunGB = 0.0f;
//Memory Enforcer
// Returns true if memory is within safe limits, false if critically high
// STRATEGY: Check total system RAM usage (all processes) and keep safetyMarginGB free
bool EnforceMemoryLimits(TileCache& tileCache, float totalSystemRAM_GB, float safetyMarginGB, bool forceCheck = false) {
#ifdef _WIN32
static float lastCheck = 0.0f;
float currentTime = ImGui::GetTime();
// Only do expensive check every 0.5 seconds (unless forced)
if (!forceCheck && currentTime - lastCheck < 0.5f) {
return true; // Assume OK between checks
}
lastCheck = currentTime;
// Get total physical RAM used by all processes
float usedSystemRAM_GB = GetUsedPhysicalRAM_GB();
float freeSystemRAM_GB = totalSystemRAM_GB - usedSystemRAM_GB;
// Commit headroom, counting pagefile growth room. MUST be the effective value, not the instantaneous
// one: with a system-managed pagefile a low instantaneous reading just means Windows would grow the
// file, so throttling on it stalls allocations the OS would have happily backed. Gating on the raw
// value here is exactly what froze the cap mid-bake and made layouts stop completing.
float availCommit_GB = GetEffectiveCommit_GB();
// Also get our process usage for informational purposes
PROCESS_MEMORY_COUNTERS_EX pmc;
float ourProcessGB = 0.0f;
if (GetProcessMemoryInfo(GetCurrentProcess(), (PROCESS_MEMORY_COUNTERS*)&pmc, sizeof(pmc))) {
ourProcessGB = pmc.WorkingSetSize / (1024.0f * 1024.0f * 1024.0f);
}
// Memory pressure response: STOP GROWING, don't shrink.
//
// Dipping below the margin means "allocate no more", not "give tiles back". Tiles already allocated
// cost nothing to keep and are the entire point of the cache, so evicting them to recover a fraction
// of a tile's worth of RAM is pure loss - it throws away work the user can see.
//
// This deliberately does NOT lower the cap by a tile on every call. Doing so over-evicted badly:
// freeing a texture doesn't return its RAM to the OS synchronously (the driver releases lazily), so
// the next check still read low, lowered the cap again, and kept going until the driver caught up -
// tens of tiles gone over a fraction-of-a-tile shortfall, and the cap never recovered afterward.
// Eviction is now driven only by EvictLRU() enforcing this cap, i.e. when tiles genuinely exceed it.
//
// Either resource running short freezes allocation, but they get different thresholds because they
// fail differently. RAM keeps the full safety margin - overrunning it means Windows kills us. Commit
// only needs room for the tile actually in flight: it has already had kDiskReserveGB held back inside
// GetEffectiveCommit_GB(), and running short grows the pagefile rather than killing the process.
// Charging the RAM margin against commit too (the earlier version) double-counted it and tripped this
// branch immediately, which is what capped the tile cache below its old value.
const float commitFloorGB = TileCache::TileSizeGB() * 2.0f;
bool lowRAM = freeSystemRAM_GB < safetyMarginGB;
bool lowCommit = availCommit_GB < commitFloorGB;
if (lowRAM || lowCommit) {
int currentAllocated = tileCache.GetAllocatedTileCount();
// Freeze allocation at the current level. Only ever lowers the cap to what is already allocated,
// so this cannot trigger an eviction by itself.
if (currentAllocated < TileCache::MAX_CACHED_TILES) {
printf("\n*** Low %s: %.1f GB free RAM, %.1f GB usable commit (floors: %.1f / %.2f GB)"
" - pausing tile allocation ***\n",
lowCommit ? "commit" : "RAM", freeSystemRAM_GB, availCommit_GB,
safetyMarginGB, commitFloorGB);
printf(" System total: %.1f GB, Used: %.1f GB (our process: %.1f GB)\n",
totalSystemRAM_GB, usedSystemRAM_GB, ourProcessGB);
printf(" Holding %d cached tiles; will resume if memory frees up\n", currentAllocated);
TileCache::MAX_CACHED_TILES = currentAllocated;
// Don't let the recovery path below hand this budget straight back on the next 0.5s tick.
// The ceiling was computed at startup from a prediction; pressure is a measurement, and the
// measurement wins.
TileCache::TILE_BUDGET_CEILING = currentAllocated;
}
// LAST RESORT: actually shed tiles. Freezing allocation above is not sufficient on its own,
// because past this point our working set keeps climbing WITHOUT us allocating anything. Tile
// textures live partly in VRAM, and as the set outgrows the card the driver progressively spills
// already-allocated tiles back into system memory - measured on a 9.4 GB card: 66 tiles (8.25 GB
// of tile bytes) sat at a 1.01 GB working set with everything resident on the GPU, while 200 tiles
// (25.0 GB) sat at 19.22 GB. Nothing new was allocated between those points in the tile count that
// the pause could have stopped; the RAM arrived on its own. That is why the warning fires and free
// RAM keeps falling anyway, and it is the mechanism behind the silent process death.
//
// So below a hard floor - genuinely about to be killed, not merely inside the margin - give tiles
// back. This is the only lever that returns memory, and the trade is no longer close: a few tiles
// going blank (they re-bake when memory allows) beats the process disappearing.
const float kCriticalRAM_GB = safetyMarginGB * 0.5f;
const int kMinTilesToKeep = 4; // enough to keep something on screen while we recover
if (freeSystemRAM_GB < kCriticalRAM_GB && currentAllocated > kMinTilesToKeep) {
// Shed the shortfall in tiles, plus one so we land clear of the floor rather than on it.
int shed = (int)((kCriticalRAM_GB - freeSystemRAM_GB) / TilePhysicalCostGB()) + 1;
int target = currentAllocated - shed;
if (target < kMinTilesToKeep) target = kMinTilesToKeep;
printf("*** CRITICAL: %.1f GB free RAM is below the %.1f GB hard floor"
" - shedding %d of %d tiles to stay alive ***\n",
freeSystemRAM_GB, kCriticalRAM_GB, currentAllocated - target, currentAllocated);
TileCache::MAX_CACHED_TILES = target;
TileCache::TILE_BUDGET_CEILING = target;
tileCache.EvictLRU(true); // may free VISIBLE tiles - see EvictLRU; nothing else is left here
}
return false; // Memory pressure detected - callers must not allocate
}
// Recovery: RAM freed up, so let the cap climb back toward the startup budget. Without this, any
// brief dip (another process spiking, a driver paging burst) lowered the cap permanently and the
// tile count could only fall over the life of a session.
//
// Requires clearing the margin by a full tile plus a margin of its own, not just the margin itself:
// recovering the instant we cross back would re-allocate, dip below again and oscillate.
const float kRecoveryHysteresisGB = TileCache::TileSizeGB() * 2.0f;
if (TileCache::MAX_CACHED_TILES < TileCache::TILE_BUDGET_CEILING &&
freeSystemRAM_GB > safetyMarginGB + kRecoveryHysteresisGB &&
availCommit_GB > commitFloorGB + kRecoveryHysteresisGB) {
// Step up by the headroom actually available, so recovery tracks real memory rather than a guess.
// Gated on BOTH resources at their own per-tile cost and their own floor - commit uses
// commitFloorGB, not the RAM safety margin, for the reasons given at the pressure check above.
//
// No VRAM credit is added here, unlike TileBudgetForMemory(): freeSystemRAM_GB is a LIVE reading,
// so whatever the card is already absorbing for the current tiles is priced into it. Crediting it
// again would count it twice and hand back the overshoot this model exists to prevent.
int affordableRAM = (int)((freeSystemRAM_GB - safetyMarginGB - kRecoveryHysteresisGB)
/ TilePhysicalCostGB());
int affordableCommit = (int)((availCommit_GB - commitFloorGB - kRecoveryHysteresisGB)
/ TileCache::TileSizeGB());
int affordable = affordableRAM < affordableCommit ? affordableRAM : affordableCommit;
// Ramp rather than jumping the whole headroom: each new tile's real cost only shows up in
// freeSystemRAM_GB after the driver commits it, so a single large step can allocate straight
// through the margin before the next check (0.5s later) can see it.
const int kMaxRecoveryStep = 4;
if (affordable > kMaxRecoveryStep) affordable = kMaxRecoveryStep;
if (affordable > 0) {
int raised = TileCache::MAX_CACHED_TILES + affordable;
TileCache::MAX_CACHED_TILES = raised > TileCache::TILE_BUDGET_CEILING
? TileCache::TILE_BUDGET_CEILING : raised;
}
}
return true; // Memory OK
#else
return true; // Non-Windows, no check
#endif
}
// Get GPU VRAM in GB
float GetGPU_VRAM_GB() {
GLint totalMemKB = 0;
// Try NVIDIA extension
glGetIntegerv(0x9049, &totalMemKB); // GL_GPU_MEMORY_INFO_TOTAL_AVAILABLE_MEMORY_NVX
if (totalMemKB > 0) {
return totalMemKB / (1024.0f * 1024.0f); // KB to GB
}
// Try AMD extension
GLint memInfo[4] = {0};
glGetIntegerv(0x87FC, memInfo); // GL_VBO_FREE_MEMORY_ATI
if (memInfo[0] > 0) {
return memInfo[0] / (1024.0f * 1024.0f); // KB to GB
}
// Intel GPU detection (Battlemage/Arc)
const char* renderer = (const char*)glGetString(GL_RENDERER);
if (renderer && strstr(renderer, "Intel")) {
GLint maxTexSize = 0;
glGetIntegerv(GL_MAX_TEXTURE_SIZE, &maxTexSize);
// Heuristic for discrete Intel GPUs
if (maxTexSize >= 16384) {
return 12.0f; // Battlemage typically has 12GB
}
}
// Fallback
return 4.0f;
}
// Tile bytes the GPU holds resident, and which therefore do NOT land in our working set.
//
// A tile reserves TILE_SIZE^2 * 2 bytes, but that is its COMMIT cost, not its physical cost. Tiles are
// GL textures: part of the set is resident in VRAM at any moment, and the driver's system-memory copy of
// that part is not charged to us. Measured on a 31.7 GB / 9.2 GB-VRAM machine baking a 128-tile layout
// (16.0 GB of tile bytes): free RAM fell 21.5 -> 10.0 GB, i.e. 11.5 GB physical for 16.0 GB of bytes -
// so ~4.5 GB of the set was living on the card.
//
// An ABSOLUTE CAP, not a fraction of each tile. This is the correction for the budget creeping up on the
// RAM ceiling: the card holds what it holds no matter how many tiles exist, so once the set exceeds VRAM
// every FURTHER tile costs its full bytes in RAM. Charging a flat 0.75 of every tile instead made the
// model's error grow with tile count - calibrated at 128 tiles it broke even near 144, then under-charged
// by ~1.5 GB at 191, which swallowed half the safety margin and left free RAM within ~2 GB of the cliff.
// Subtracting a lump credit keeps the low-count generosity (where the card really does hold most of the
// set) while charging the marginal tile honestly at the high end, where the overshoot was.
//
// Half of VRAM: the driver also needs the card for the framebuffer, the node/edge/text vertex buffers and
// the SPH SSBOs, so the whole of it is never available to tiles. Conservative on purpose - under-crediting
// costs a few tiles, over-crediting walks back into the RAM ceiling.
float TileVRAMCreditGB() {
float credit = GetGPU_VRAM_GB() * 0.5f;
return credit > 0.0f ? credit : 0.0f;
}
// Physical-RAM cost of one allocated tile at the margin, as opposed to TileSizeGB() which is its commit
// cost. Full bytes: the VRAM credit is applied once to the whole set (see TileVRAMCreditGB), not per tile,
// so the next tile allocated past saturation costs all of itself in RAM.
float TilePhysicalCostGB() {
return TileCache::TileSizeGB();
}
// Heap cost of the NON-TILE structures: the data model, the layout maps, the node view cache, the edge
// render cache and the spatial buckets. These are not tiles, but they are charged to the same physical RAM
// and the same commit, and until now the budget model ignored them entirely - which is precisely why free
// RAM ended up 2 GB from the ceiling (and sometimes past it) even with the tile count correctly capped.
// At 650k nodes they are 2.4 GB, i.e. most of a 3.2 GB safety margin, spent before a single tile is priced.
//
// Two coefficients, because the cost genuinely has both terms and the ratio between them is not fixed:
// per node - data model 730 B, node view cache 1419 B (NodeView + its per-port allocations),
// layout maps 97 B, node buckets 29 B.
// per edge - edge render cache 93 B: a 48 B CachedEdge, its polyline in edgePointPool_ (16 B for the
// straight 2-point edges the grid generates) and the edgeIndexByNode_ entry. Deeper routes
// add 8 B per extra waypoint, so this term is a floor for layered datasets.
//
// Measured, not derived: instrumented working-set deltas around each build stage on this machine.
// The model was fitted at 650k nodes / 3.25M edges (2.410 GB) and checked at 200k / 1.0M (predicts
// 0.741 GB, measured 0.742) - so it holds across a 3x range and across a changing edge ratio.
//
// Split into what's built EAGERLY (data model + layout, during setup) and what's built LAZILY (node view
// cache, edge render cache, buckets - all inside the first bake). The split matters because the two
// budget call sites sit on opposite sides of it: the auto-sizer runs before the model exists and must
// reserve the whole cost, while CheckTileSafety runs after the layout, where the eager part is already
// visible in the live free-RAM reading and charging it again would understate the budget by ~0.5 GB.
// Edges per node in the synthetic grid dataset (DataModel::CreateNodesGrid connects each node to the
// previous one with 5 edges). The auto-sizer needs the edge count before the model exists, so it can't
// read it back - keep this in step with CreateNodesGrid or the reserved heap will be wrong.
const int kGridEdgesPerNode = 5;
const float kGraphHeapEagerBytesPerNode = 827.0f; // data model 730 + layout maps 97
const float kGraphHeapLazyBytesPerNode = 1448.0f; // node view cache 1419 + node buckets 29
const float kGraphHeapLazyBytesPerEdge = 93.0f; // edge render cache (CachedEdge 48 + points 16 + index)
// Heap still to be allocated once the layout exists: the caches the first bake builds.
float GraphHeapLazyGB(long long nodes, long long edges) {
if (nodes <= 0) return 0.0f;
double bytes = (double)nodes * kGraphHeapLazyBytesPerNode + (double)edges * kGraphHeapLazyBytesPerEdge;
return (float)(bytes / 1073741824.0);
}
// Total heap the graph will occupy, for callers deciding a node count before anything is allocated.
float GraphHeapTotalGB(long long nodes, long long edges) {
if (nodes <= 0) return 0.0f;
return GraphHeapLazyGB(nodes, edges)
+ (float)((double)nodes * kGraphHeapEagerBytesPerNode / 1073741824.0);
}
// How many tiles fit in memory. Tile cost is content-independent (an RGB565 tile reserves
// TILE_SIZE^2 * 2 bytes whether it holds nodes or not), so the per-tile figure is exact.
//
// Two independent limits, both real, so this takes the MIN:
// physical - free RAM minus the safety margin, at TilePhysicalCostGB() per tile. Exceeding it means
// Windows kills the process (it does not fail the allocation first; see SAFETY_MARGIN_GB).
// commit - available pagefile-backed commit, at the FULL TileSizeGB() per tile, since commit is
// charged on allocation regardless of residency. The old physical-only model ignored this
// entirely, and it is the limit behind the silent crashes: at high node counts the tile set
// plus the node/edge structures exhausted commit while free RAM still looked healthy, so
// nothing throttled and an allocation failed hard. Measured at 146 tiles (18.25 GB of tile
// bytes): working set 11.9 GB but commit 21.4 GB, against a 39.3 GB commit limit.
//
// VRAM is neither added nor used as a cap: 207 allocated tiles (25.9 GB of tile bytes) coexisted on a
// 10 GB card, so the driver pages tiles between VRAM and system memory and VRAM is NOT a tile ceiling.
// Its effect on the budget is a one-time credit against the set's bytes - see TileVRAMCreditGB().
// graphHeapGB: the non-tile heap the graph will occupy (see GraphHeapCostGB). Reserved off the top of BOTH
// limits before any tile is priced. Callers that don't know the graph size yet pass 0, but the auto-sizing
// path must pass it - a 650k-node graph's 2.4 GB is ~19 tiles' worth of budget handed out twice otherwise.
int TileBudgetForMemory(float safetyMarginGB, float graphHeapGB = 0.0f) {
float usableRAM = (GetSystemRAM_GB() - GetUsedPhysicalRAM_GB()) - safetyMarginGB - graphHeapGB;
if (usableRAM < 0.0f) usableRAM = 0.0f;
// Solve N * TileSizeGB() - vramCredit <= usableRAM for N: the whole set costs its bytes minus what
// the card absorbs, so the credit is added ONCE here rather than discounted from every tile.
int byPhysical = (int)((usableRAM + TileVRAMCreditGB()) / TilePhysicalCostGB());
// Commit gets TWO ceilings, both applied, because it can run out for two unrelated reasons:
//
// free RAM - the same reading as above but WITHOUT the VRAM credit, since commit gets no credit for
// residency: a tile living on the card still holds the commit for its system-memory
// copy. This is what keeps the tile set from REQUIRING pagefile backing - the commit a
// budget can't cover in RAM has to be paged, and Windows grows a system-managed
// pagefile synchronously to provide it, which stalls the process hard (gCommitOverrunGB
// relaxes this deliberately).
// commit limit - the actual ceiling, for the opposite case: a small pagefile on a nearly full disk,
// where commit binds before RAM does.
//
// Free RAM is usually the tighter of the two. The commit limit only binds with little free disk, since
// pagefile growth room counts toward it (see GetEffectiveCommit_GB).
float usableCommitRAM = (GetSystemRAM_GB() - GetUsedPhysicalRAM_GB()) - safetyMarginGB - graphHeapGB
+ gCommitOverrunGB;
if (usableCommitRAM < 0.0f) usableCommitRAM = 0.0f;
int byCommit = (int)(usableCommitRAM / TileCache::TileSizeGB());
float growthRoom = GetEffectiveCommit_GB() - graphHeapGB;
if (growthRoom < 0.0f) growthRoom = 0.0f;
int byCommitLimit = (int)(growthRoom / TileCache::TileSizeGB());
if (byCommitLimit < byCommit) byCommit = byCommitLimit;
return byPhysical < byCommit ? byPhysical : byCommit;
}
// Tiles the layout for `nodes` grid nodes will actually need. Rounds up per axis: partial tiles on the
// right/bottom edges each cost a full allocation.
int GridTileCount(const LayoutEngine& layout, int nodes) {
ImVec2 s = layout.GridWorldSizeForNodeCount(nodes);
return TileCache::TilesToCover((int)s.x) * TileCache::TilesToCover((int)s.y);
}
// Pick a default grid node count sized to the tile budget.
//
// Compares TILE COUNTS, not areas. The grid is square in node count (nodesPerRow = sqrt(N)) but cells
// are ~2.5:1, so the world lands ~2:1 and quantizes on both axes - e.g. 16.7 x 8.4 tiles of area
// becomes 17 x 9 = 153 tiles, ~10% over what an area-only budget allows. Start from the area estimate
// (always an over-estimate) and shrink until the real tile grid fits; world size is monotonic in N.
//
// Charges the graph's own heap against the budget, which makes this a FIXED POINT rather than a division:
// more nodes means more non-tile heap (GraphHeapCostGB), which leaves fewer tiles, which allows fewer
// nodes. So it iterates to convergence instead of dividing once.
//
// This replaced a flat `tileBudget * 0.95` headroom. A fraction of the TILE budget is the wrong shape for
// this cost twice over: the real cost scales with the node count (which is the thing being solved for, not
// the tile count), and 5% understated it badly at the top end - at 650k nodes the structures are 2.4 GB
// against a 25 GB tile budget, so ~10%, and the missing 5% is what ate half the safety margin.
int AutoGridNodeCount(const LayoutEngine& layout, float safetyMarginGB, int edgesPerNode) {
const long long kMinNodes = 10000, kMaxNodes = 2000000;
// Area-based starting point: nodes = tiles * (tileArea / cellArea). Cell pitch is the growth from a
// 1x1 grid to a 2x2 one, which cancels the fixed padding on both axes.
ImVec2 one = layout.GridWorldSizeForNodeCount(1);
ImVec2 four = layout.GridWorldSizeForNodeCount(4);
double cellArea = (double)(four.x - one.x) * (double)(four.y - one.y);
if (cellArea <= 0.0) return (int)kMinNodes;
double tileArea = (double)TileCache::TILE_SIZE * (double)TileCache::TILE_SIZE;
long long nodes = 0;
// Converges in 2-3 passes: each pass prices the previous pass's node count and re-solves. Capped so a
// pathological oscillation can't spin here. Monotone decreasing in practice (a bigger N only ever
// reserves more heap), so the last value is the safe one.
const int kMaxPasses = 8;
for (int pass = 0; pass < kMaxPasses; ++pass) {
// Total, not lazy: nothing is allocated yet at this point, so the whole cost is still ahead of us.
int budget = TileBudgetForMemory(safetyMarginGB,
GraphHeapTotalGB(nodes, nodes * (long long)edgesPerNode));
if (budget <= 0) return (int)kMinNodes;
long long next = (long long)(budget * (tileArea / cellArea));
if (next > kMaxNodes) next = kMaxNodes;
// Shrink until the integral tile grid fits (typically 1-2 passes; step scales with N).
while (next > kMinNodes && GridTileCount(layout, (int)next) > budget)
next -= (next / 32 > 0 ? next / 32 : 1);
if (next < kMinNodes) next = kMinNodes;
// Settled (within a rounding step of the last pass) - stop.
if (nodes != 0 && llabs(next - nodes) <= nodes / 64) { nodes = next; break; }
nodes = next;
}
return (int)(nodes < kMinNodes ? kMinNodes : nodes);
}
// Safety check: compute the tile budget for this layout and warn if it won't fit.
// Returns the max tiles that fit in RAM; the caller applies it to TileCache::MAX_CACHED_TILES.
// Default margin here is only a fallback - both call sites pass the main loop's SAFETY_MARGIN_GB.
// Keep the two in step so the startup prediction can't disagree with the runtime enforcement.
// graphHeapGB: the non-tile heap already spent on this graph, reserved before tiles are priced. Passing the
// real value is what keeps the startup prediction equal to what the runtime will actually allow.
int CheckTileSafety(int width, int height, const char* context = "layout", const float SAFETY_MARGIN_GB = 3.0f,
float graphHeapGB = 0.0f) {
static float systemRAM_GB = GetSystemRAM_GB();
static float gpuVRAM_GB = GetGPU_VRAM_GB();
static bool firstRun = true;
if (firstRun) {
firstRun = false;
printf("\n=== SYSTEM MEMORY ANALYSIS ===\n");
printf("GPU: %s\n", (const char*)glGetString(GL_RENDERER));
printf("System RAM: %.1f GB total\n", systemRAM_GB);
printf("GPU VRAM: %.1f GB %s\n", gpuVRAM_GB,
(gpuVRAM_GB == 4.0f || gpuVRAM_GB == 12.0f) ? "(estimated)" : "(detected)");
printf("Note: Tile allocation enforced at runtime (stops when < %.1f GB RAM or commit free)\n",
SAFETY_MARGIN_GB);
printf("=============================\n\n");
}
const float TILE_SIZE_GB = TileCache::TileSizeGB();
// Round UP on both axes: a partially covered tile still costs a full allocation. Truncating here
// used to under-report the grid (e.g. 15x8=120 tiles reported as 15x7=105) and hid the overflow.
int tilesX = TileCache::TilesToCover(width);
int tilesY = TileCache::TilesToCover(height);
int totalTiles = tilesX * tilesY;
float estimatedMem_GB = totalTiles * TILE_SIZE_GB;
// Get current system memory usage
float totalSystemRAM_GB = GetSystemRAM_GB();
float usedSystemRAM_GB = GetUsedPhysicalRAM_GB();
float freeSystemRAM_GB = totalSystemRAM_GB - usedSystemRAM_GB;
float usableRAM_GB = freeSystemRAM_GB - SAFETY_MARGIN_GB - graphHeapGB;
float availCommit_GB = GetEffectiveCommit_GB() - graphHeapGB;
// Delegate to the one budget model so this prediction cannot drift from what the runtime enforces.
// A tile costs only part of its bytes in physical RAM but all of them in commit, and the budget is
// the min of those two limits - see TileBudgetForMemory().
int estimatedMaxTiles = TileBudgetForMemory(SAFETY_MARGIN_GB, graphHeapGB);
printf("\n=== TILE SAFETY CHECK (%s) ===\n", context);
printf("World size: %i × %i pixels\n", width, height);
printf("Tile grid: %i × %i = %d tiles (%.1f GB)\n", tilesX, tilesY, totalTiles, estimatedMem_GB);
printf("System RAM: %.1f GB total, %.1f GB used, %.1f GB free\n",
totalSystemRAM_GB, usedSystemRAM_GB, freeSystemRAM_GB);
printf("Usable for tiles: %.1f GB RAM (free - %.1f GB margin) / %.1f GB commit"
" (%.1f GB free + pagefile growth room, less %.1f GB disk reserve)\n",
usableRAM_GB, SAFETY_MARGIN_GB, availCommit_GB,
GetAvailableCommit_GB(), kDiskReserveGB);
// A tile costs the same bytes in RAM and commit; what differs is that the RAM side gets a one-time
// credit for the part of the set the card absorbs. Print both so it's clear which limit is binding.
printf("Per-tile cost: %.3f GB RAM / %.3f GB commit, less a one-time %.1f GB VRAM credit"
" (half of %.1f GB detected)\n",
TilePhysicalCostGB(), TILE_SIZE_GB, TileVRAMCreditGB(), gpuVRAM_GB);
int tilesByRAM = (int)((ImMax(usableRAM_GB, 0.0f) + TileVRAMCreditGB()) / TilePhysicalCostGB());
// Commit is bounded by free RAM as well as by the commit limit, and gets no VRAM credit - mirror
// TileBudgetForMemory() exactly, or this line reports a limit the budget beside it doesn't use.
int tilesByCommit = (int)(ImMax(usableRAM_GB + gCommitOverrunGB, 0.0f) / TILE_SIZE_GB);
int tilesByCommitLimit = (int)(ImMax(availCommit_GB, 0.0f) / TILE_SIZE_GB);
if (tilesByCommitLimit < tilesByCommit) tilesByCommit = tilesByCommitLimit;
printf("Estimated max tiles: ~%d tiles (RAM allows %d, commit allows %d - %s is binding)\n",
estimatedMaxTiles, tilesByRAM, tilesByCommit,
tilesByRAM <= tilesByCommit ? "RAM" : "commit");
if (totalTiles > estimatedMaxTiles) {
printf("\n*** WARNING: Tile rendering will not complete - will run out of memory ***\n");
printf(" Layout needs %d tiles, but only ~%d tiles can fit in memory\n",
totalTiles, estimatedMaxTiles);
printf(" You will still be able to navigate the graph, but some tiles will not display.");
}
// Always return estimated max - runtime check will enforce the real limit
return estimatedMaxTiles;
}
// Helper: Recalculate minZoom to fit world bounds in viewport (call on re-layout or window resize)
// Also clamps currentCameraZoom to ensure it doesn't go below minZoom
void RecalculateMinZoom(float& minZoom, float& currentCameraZoom, ImVec2 viewportSize, int worldWidth, int worldHeight) {
// Add 10% buffer so padding is visible as breathing room
float minZoomX = (viewportSize.x * 0.9f) / (float)worldWidth;
float minZoomY = (viewportSize.y * 0.9f) / (float)worldHeight;
minZoom = ImMin(minZoomX, minZoomY);
// Clamp current zoom to new minimum (zoom in if needed to fit window)
currentCameraZoom = ImMax(currentCameraZoom, minZoom);
}
// Command-line configuration for the graph viewer. Populated by ParseArgs() from argv; the setup code
// below reads these instead of hardcoded values.
struct AppConfig {
bool gpuRendering = true; // --gpu on|off
int edgeRenderMode = 2; // --curve 0..3 (0=BezierDirect,1=Polyline,2=CatmullRom,3=BezierChain)
// Dataset: one of the four test hierarchies, or the grid. "grid" also honors gridNodeCount.
enum class Dataset { TestHierarchy, LargeTestHierarchy, LargeWideTestHierarchy, LongEdgeTest, Grid };
Dataset dataset = Dataset::LargeTestHierarchy;
int gridNodeCount = 300000; // --nodes N (only used when --data grid)
bool gridNodeCountExplicit = false; // true if --nodes was passed (auto-sizing is skipped then)
bool debug = false; // --debug: verbose prints, profiling, layout detail dumps
float commitOverrunGB = 0.0f; // --commit-overrun GB (see gCommitOverrunGB)
};
static void PrintUsage(const char* exe) {
printf("Usage: %s [options]\n", exe);
printf(" --gpu on|off GPU rendering (default on)\n");
printf(" --curve 0..3 Edge curve type: 0=BezierDirect 1=Polyline 2=CatmullRom 3=BezierChain (default 2)\n");
printf(" --data <name> Dataset: test | large | wide | longedge | grid (default: large) \n");
printf(" --nodes N Node count for --data grid (default: generates as many nodes that reasonably render with available ram/vram)\n");
printf(" --commit-overrun GB Let the tile budget exceed free RAM by GB, backed by the pagefile\n");
printf(" (default 0 = tiles must fit in RAM). Raises the tile ceiling, so more\n");
printf(" of a large graph can be rendered. Setting will prompt confirmation\n");
printf(" --debug Enable verbose debug output + profiling (default off)\n");
printf(" --help Show this help and exit\n");
}
// Parse argv into cfg. Returns false if the program should exit (bad args or --help).
static bool ParseArgs(int argc, char** argv, AppConfig& cfg) {
for (int i = 1; i < argc; i++) {
std::string a = argv[i];
auto next = [&](const char* name) -> const char* {
if (i + 1 >= argc) { printf("Error: %s requires a value\n", name); return nullptr; }
return argv[++i];
};
if (a == "--help" || a == "-h") { return false; } // usage already printed at startup
else if (a == "--debug") { cfg.debug = true; }
else if (a == "--gpu") {
const char* v = next("--gpu"); if (!v) return false;
std::string s = v;
if (s == "on" || s == "1" || s == "true") cfg.gpuRendering = true;
else if (s == "off" || s == "0" || s == "false") cfg.gpuRendering = false;
else { printf("Error: --gpu expects on|off (got '%s')\n", v); return false; }
}
else if (a == "--curve") {
const char* v = next("--curve"); if (!v) return false;
int m = atoi(v);
if (m < 0 || m > 3) { printf("Error: --curve expects 0..3 (got '%s')\n", v); return false; }
cfg.edgeRenderMode = m;
}
else if (a == "--data") {
const char* v = next("--data"); if (!v) return false;
std::string s = v;
if (s == "test") cfg.dataset = AppConfig::Dataset::TestHierarchy;
else if (s == "large") cfg.dataset = AppConfig::Dataset::LargeTestHierarchy;
else if (s == "wide") cfg.dataset = AppConfig::Dataset::LargeWideTestHierarchy;
else if (s == "longedge") cfg.dataset = AppConfig::Dataset::LongEdgeTest;
else if (s == "grid") cfg.dataset = AppConfig::Dataset::Grid;
else { printf("Error: --data expects test|large|wide|longedge|grid (got '%s')\n", v); return false; }
}
else if (a == "--nodes") {
const char* v = next("--nodes"); if (!v) return false;
int n = atoi(v);
if (n < 1) { printf("Error: --nodes expects a positive integer (got '%s')\n", v); return false; }
cfg.gridNodeCount = n;
cfg.gridNodeCountExplicit = true;
}
else if (a == "--commit-overrun") {
const char* v = next("--commit-overrun"); if (!v) return false;
float gb = (float)atof(v);
if (gb < 0.0f) { printf("Error: --commit-overrun expects GB >= 0 (got '%s')\n", v); return false; }
cfg.commitOverrunGB = gb;
}
else { printf("Error: unknown argument '%s'\n", a.c_str()); PrintUsage(argv[0]); return false; }
}
return true;
}
int main(int argc, char** argv)
{
// Unbuffer stdout so a redirected log survives an abnormal exit. Redirected output is block-buffered
// by default, which silently discarded the last 4 KB - i.e. exactly the memory warnings printed just
// before a low-memory death, the ones needed to diagnose it. _IONBF and not _IOLBF: MSVC's CRT
// treats line buffering as full buffering, so _IOLBF would change nothing.
setvbuf(stdout, nullptr, _IONBF, 0);
// Always show the available options first, regardless of how the program is invoked.
PrintUsage(argv[0]);
AppConfig cfg;
if (!ParseArgs(argc, argv, cfg))
return 0; // --help or a parse error already printed a message
gDebugEnabled = cfg.debug;
gCommitOverrunGB = cfg.commitOverrunGB;
// Any overrun is opt-in per run, not just per command line: the failure mode is system-wide rather
// than confined to this process, so it shouldn't be reachable by a typo or a copied command line.
if (gCommitOverrunGB > 0.0f) {
printf("\n*** WARNING: --commit-overrun %.1f GB CAN HANG YOUR ENTIRE MACHINE FOR SOME TIME. ***\n"
" System-wide hangs and application crashes have been observed at 500k+ node graphs on\n"
" systems with large enough RAM capacity to handle them.\n"
" The tile budget may exceed free RAM by %.1f GB, backed by the pagefile; Windows grows\n"
" the pagefile mid-render, which saturates the disk and freezes everything until it\n"
" finishes. Omit the flag (or pass 0) to keep tiles inside physical RAM.\n\n"
"Continue with a %.1f GB overrun? [y/N] ",
gCommitOverrunGB, gCommitOverrunGB, gCommitOverrunGB);
// EOF (piped/redirected stdin, or a closed console) counts as "no" rather than blocking forever:
// an unattended run must not sit on a prompt, and must not opt itself in either.
int c = getchar();
if (c != 'y' && c != 'Y') {
printf("\nAborted. Re-run without --commit-overrun to size the tile budget to fit in RAM.\n");
return 0;
}
while (c != '\n' && c != EOF) c = getchar(); // drop the rest of the line
printf("\nProceeding with a %.1f GB commit overrun.\n\n", gCommitOverrunGB);
}
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER | SDL_INIT_GAMECONTROLLER) != 0)
{
printf("Error: %s\n", SDL_GetError());
return -1;
}
// Decide GL+GLSL versions. Ask for 4.3 first (compute shaders), then fall back to 3.0.
#if defined(IMGUI_IMPL_OPENGL_ES2)
struct { int major, minor; const char* glsl; } tries[] = { {2, 0, "#version 100"} };
const int gl_profile = SDL_GL_CONTEXT_PROFILE_ES;
const int gl_flags = 0;
#elif defined(__APPLE__)
// Apple caps desktop GL at 4.1, so 4.3 is never available.
struct { int major, minor; const char* glsl; } tries[] = { {3, 2, "#version 150"} };
const int gl_profile = SDL_GL_CONTEXT_PROFILE_CORE;
const int gl_flags = SDL_GL_CONTEXT_FORWARD_COMPATIBLE_FLAG; // Always required on Mac
#else
struct { int major, minor; const char* glsl; } tries[] = { {4, 3, "#version 150"},
{3, 0, "#version 130"} };
const int gl_profile = SDL_GL_CONTEXT_PROFILE_CORE;
const int gl_flags = 0;
#endif
//MSAA works on the window, not on the FBO's. So not turning on MSAA here.
SDL_WindowFlags window_flags =
(SDL_WindowFlags)(SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI);
SDL_Window* window = nullptr;
SDL_GLContext gl_context = nullptr;
const char* glsl_version = nullptr;
// SDL keeps the failed attempt's pixel format on the window, so recreate the window each try.
for (int i = 0; i < (int)(sizeof(tries) / sizeof(tries[0])) && !gl_context; i++) {
SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, gl_flags);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, gl_profile);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, tries[i].major);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, tries[i].minor);
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24);
SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8);
window = SDL_CreateWindow("Dear ImGui SDL2+OpenGL3 example", SDL_WINDOWPOS_CENTERED,
SDL_WINDOWPOS_CENTERED, 1280, 720, window_flags);
if (window) {
gl_context = SDL_GL_CreateContext(window);
if (gl_context) { glsl_version = tries[i].glsl; break; }
SDL_DestroyWindow(window);
window = nullptr;
}
printf("GL %d.%d context unavailable (%s)\n", tries[i].major, tries[i].minor, SDL_GetError());
}
if (!gl_context) {
printf("Error: could not create an OpenGL context: %s\n", SDL_GetError());
return -1;
}
SDL_GL_MakeCurrent(window, gl_context);
SDL_GL_SetSwapInterval(1); // Enable vsync (disable for fps testing)
// Log the GPU we landed on: distinguishes "driver too old" from "Windows ran us on the iGPU".
GLint glMajor = 0, glMinor = 0;
glGetIntegerv(GL_MAJOR_VERSION, &glMajor);
glGetIntegerv(GL_MINOR_VERSION, &glMinor);
printf("OpenGL %d.%d | %s | %s\n", glMajor, glMinor,
glGetString(GL_RENDERER) ? (const char*)glGetString(GL_RENDERER) : "(unknown renderer)",
glGetString(GL_VERSION) ? (const char*)glGetString(GL_VERSION) : "(unknown version)");
// Setup Dear ImGui context
IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImGuiIO& io = ImGui::GetIO();
(void)io;
// Configure ImGui UI font (for menus, controls, etc. - NOT for node graph text)
// Node graph text uses pre-generated SDF atlas loaded from font.png/font.fnt
ImFontConfig fontConfig;
fontConfig.SizePixels = 13.0f; // Default UI font size
fontConfig.OversampleH = 1;
fontConfig.OversampleV = 1;
fontConfig.GlyphRanges = io.Fonts->GetGlyphRangesDefault(); // Full ASCII 32-127
io.Fonts->AddFontDefault(&fontConfig);
printf("ImGui UI font configured: size=%.1f (for menus/controls only)\n", fontConfig.SizePixels);
// Setup Dear ImGui style
ImGui::StyleColorsDark();
// ImGui::StyleColorsClassic();
// Setup Platform/Renderer backends
ImGui_ImplSDL2_InitForOpenGL(window, gl_context);
ImGui_ImplOpenGL3_Init(glsl_version);
// Load OpenGL extension functions (centralized in GLFunctions.h)
if (!InitializeGLFunctions()) {
printf("ERROR: Failed to initialize OpenGL functions\n");
return -1;
}
// Setup Tile Cache for rendering (The Tile Cache keeps track of FBO tiles that make up the whole world space)
TileCache tileCache;
tileCache.Initialize();
// Graph View world space dimensions (calculated from node bounds later)
int width;
int height;
ImVec4 clear_color = ImVec4(0.45f, 0.55f, 0.60f, 1.00f);
//Initialize the core library architecture
static GraphData::DataModel* dataModel = nullptr;
static LayoutEngine* layoutEngine = nullptr;
static ImGui::GraphView graphView;
// GPU rendering configuration (from --gpu on|off).
bool useGPURendering = cfg.gpuRendering;
GPURenderer gpuRenderer;
// GPU physics layout simulation (SPH skeleton: press P to nudge nodes off-home).
PhysicsLayoutSimulation physicsSim;
bool physicsActive = false; // true while an animation is settling (overlay draw + step)
// Keeps the overlay drawing for ONE extra frame after commit, covering the gap until the tiles
// re-bake (which happens at the top of the NEXT frame). Without it, the settle frame shows the
// still-hole-punched tiles with the overlay already off -> a one-frame empty parent + stray edges.
bool physicsOverlayLinger = false;
// Subgraph drag: when a left-drag starts on a subgraph boundary's empty area (not on a node),
// the whole subgraph moves as a unit. Holds the parent id being dragged (-1 = none).
int draggedSubgraphId = -1;
// Cursor world position at the previous drag frame, and whether it's valid yet.
//
// Drags derive their delta from consecutive io.MousePos samples rather than io.MouseDelta, so that
// motion is never lost to a frame that doesn't reach the drag branch. MouseDelta is per-frame and
// resets each NewFrame(), so any frame that skips the drag block silently discards that frame's
// travel; differencing world positions instead accumulates across the gap and stays exact.
//
// The frame-skipping itself is fixed in the bake loop (see the window-revive comment there). This is
// belt-and-braces on top of it: it keeps the drag exact even if some future path - a slow bake, a
// popup grabbing hover, an early-out - costs the drag a frame again.
ImVec2 dragPrevMouseWorld = ImVec2(0.0f, 0.0f);
bool dragPrevMouseWorldValid = false;
// State captured when an expand/collapse animation begins, consumed once on settle to commit
// the animated positions into the layout (tiles + hit-test).
bool physicsCommitPending = false;
std::map<int, ImVec2> physicsOldPositions; // layout snapshot before the transition
std::map<int, ImVec2> physicsNewSizes; // sizes for nodes appearing this transition (children)
int physicsParentId = -1; // parent being expanded (removed from layout on commit)
// Collapse-specific state (two-phase: children shrink in, then neighbors spring home).
bool physicsCollapsePending = false;
int physicsCollapsePhase = 0; // 1 = children shrinking, 2 = neighbors returning
int physicsCollapseParentId = -1; // the parent reappearing after collapse
std::vector<int> physicsCollapseChildIds; // children removed from the sim + layout on settle
std::vector<int> physicsCollapseNeighbors; // neighbors that spring home in phase 2
bool nodeSetup = false;
bool nodesDirty = true;
// Main loop with event-driven rendering
bool done = false;
bool needsRender = true; // Force initial render
bool isInteracting = false; // Track if user is actively interacting
std::vector<float> extent;
// Free RAM to keep in reserve, i.e. the ceiling on tile allocation. Windows kills the process
// outright rather than failing an allocation when it runs dry, so the reactive eviction below never
// gets a chance to give anything back - this margin is what prevents reaching that point at all.
//
// Two components, which is why it's a max() and not a single number:
// kMinMarginGB - working room for the OS, the GPU driver and other processes. Absolute: a small
// machine needs just as much of it, so this must never scale down.
// fraction - the driver's system-memory paging copies of the resident tile set, which keep
// growing for a moment after we stop allocating. That lag is proportional to how
// much tile memory is in flight, and the tile budget is itself sized from RAM, so
// on a big machine the overshoot is bigger and needs more headroom. A fixed 2.0 GB
// was enough until the tile budget grew large enough to overshoot it: allocation
// ran free RAM down to 1.9 GB and the process died there.
// On a small machine the floor wins and this is just the old 2.0 GB behaviour, which is correct -
// such a machine can't hold a graph big enough to produce the overshoot in the first place.
const float kMinMarginGB = 2.0f;
static const float SAFETY_MARGIN_GB = ImMax(kMinMarginGB, GetSystemRAM_GB() * 0.10f);
while (!done)
{
// Get actual window size for UI rendering (not FBO)
int window_width, window_height;
SDL_GetWindowSize(window, &window_width, &window_height);
// Event-driven rendering: wait for events when idle, poll continuously during interaction
SDL_Event event;
if (!needsRender && !isInteracting && !nodesDirty) {
// Idle: wait up to 100ms for event (allows 10 FPS, for less GPU utilization go lower)
if (SDL_WaitEventTimeout(&event, 100)) {
ImGui_ImplSDL2_ProcessEvent(&event);
if (event.type == SDL_QUIT) done = true;
needsRender = true; // Event occurred, render tiles
}
}
// Camera state
static ImVec2 cameraOffset = ImVec2(0.0f, 0.0f); //World position at top left of viewport
static float cameraZoom = 0.5f; // Start zoomed out (will auto-fit on first scroll)
static float minZoom = 0.1f; // Minimum zoom (fit entire world)
// Track Node Editor window size from previous frame for tile visibility
static ImVec2 nodeEditorSize = ImVec2((float)window_width, (float)window_height);
// Process any pending events
while (SDL_PollEvent(&event)) {
ImGui_ImplSDL2_ProcessEvent(&event);
if (event.type == SDL_QUIT) done = true;
if (event.type == SDL_WINDOWEVENT && event.window.event == SDL_WINDOWEVENT_RESIZED) {
// Recalculate minZoom when SDL window is resized
int newWidth, newHeight;
SDL_GetWindowSize(window, &newWidth, &newHeight);
RecalculateMinZoom(minZoom, cameraZoom, ImVec2((float)newWidth, (float)newHeight), width, height);
}
needsRender = true;
}
// Node setup (first frame only)
if (!nodeSetup) {
// Need ImGui context for font system during node creation
ImGui_ImplOpenGL3_NewFrame();
ImGui_ImplSDL2_NewFrame();
ImGui::NewFrame();
bool useGrid = (cfg.dataset == AppConfig::Dataset::Grid);
// Step 1: Create the layout engine first - auto-sizing the grid needs its cell pitch to
// convert a tile budget into a node count.
layoutEngine = new LayoutEngine();
layoutEngine->SetGridParameters(
150.0f, // nodeWidth (base width before dynamic calculation)
50.0f, // nodeHeight
50.0f, // horizontalSpacing (used for layerSpacing = 2x)
50.0f // verticalSpacing (gap between nodes in same layer)
);
// Step 2: Create data model (from --data, and --nodes for the grid).
dataModel = new GraphData::DataModel();
// For the grid with no explicit --nodes, size it to the tiles that fit VRAM + free RAM,
// net of the heap the graph's own structures will take (see GraphHeapTotalGB).
if (useGrid && !cfg.gridNodeCountExplicit) {
cfg.gridNodeCount = AutoGridNodeCount(*layoutEngine, SAFETY_MARGIN_GB, kGridEdgesPerNode);
float graphHeap = GraphHeapTotalGB(cfg.gridNodeCount,
(long long)cfg.gridNodeCount * kGridEdgesPerNode);
int tileBudget = TileBudgetForMemory(SAFETY_MARGIN_GB, graphHeap);
printf("Default (auto-sized) node count: rendering %d nodes "
"(%d tiles; limited by %.1f GB free RAM / %.1f GB free commit, "
"less %.2f GB reserved for the graph's own structures)\n",
cfg.gridNodeCount, tileBudget,
GetSystemRAM_GB() - GetUsedPhysicalRAM_GB(), GetAvailableCommit_GB(), graphHeap);
}
switch (cfg.dataset) {
case AppConfig::Dataset::TestHierarchy: dataModel->CreateTestHierarchy(); break;
case AppConfig::Dataset::LargeTestHierarchy: dataModel->CreateLargeTestHierarchy(); break;
case AppConfig::Dataset::LargeWideTestHierarchy: dataModel->CreateLargeWideTestHierarchy(); break;
case AppConfig::Dataset::LongEdgeTest: dataModel->CreateLongEdgeTest(); break;
case AppConfig::Dataset::Grid: dataModel->CreateNodesGrid(cfg.gridNodeCount); break;
}