From b85e1c7300c8750d07dcc622a16afa64408f9adb Mon Sep 17 00:00:00 2001 From: Ruffled <105522716+RuffledPlume@users.noreply.github.com> Date: Sun, 8 Mar 2026 18:20:33 +0000 Subject: [PATCH] Improve handling of low memory scenarios * Reuse Previous SceneContext Data * Added GC Hint to try to catch when a OOM is impending * Moved Model Pushing Arrays into LegacySceneContext Class * Print Memory stats before & after hinting GC * Track Memory State across the last 64 frames * This allows us to determine if we're consistently running close to the edge of the max memory * Override Expanded Chunk Loading if we're running low on memory, effectively forcing lowMemory mode --- src/main/java/rs117/hd/HdPlugin.java | 2 +- .../hd/renderer/legacy/LegacyModelPusher.java | 4 +- .../renderer/legacy/LegacySceneContext.java | 6 ++ .../rs117/hd/renderer/zone/SceneManager.java | 78 +++++++++++++++++-- .../hd/renderer/zone/WorldViewContext.java | 1 + .../rs117/hd/renderer/zone/ZoneUploadJob.java | 6 -- .../rs117/hd/scene/ProceduralGenerator.java | 68 +++++++++++++--- .../java/rs117/hd/scene/SceneContext.java | 6 -- src/main/java/rs117/hd/utils/HDUtils.java | 75 ++++++++++++++++++ 9 files changed, 213 insertions(+), 33 deletions(-) diff --git a/src/main/java/rs117/hd/HdPlugin.java b/src/main/java/rs117/hd/HdPlugin.java index 5a14d117b0..d6b56a99f2 100644 --- a/src/main/java/rs117/hd/HdPlugin.java +++ b/src/main/java/rs117/hd/HdPlugin.java @@ -1935,7 +1935,7 @@ public float getGammaCorrection() { } public int getExpandedMapLoadingChunks() { - if (useLowMemoryMode) + if (useLowMemoryMode || sceneManager.isExpandedMapLoadingChunksOverridden()) return 0; return config.expandedMapLoadingChunks(); } diff --git a/src/main/java/rs117/hd/renderer/legacy/LegacyModelPusher.java b/src/main/java/rs117/hd/renderer/legacy/LegacyModelPusher.java index f00c54fd45..205fd08488 100644 --- a/src/main/java/rs117/hd/renderer/legacy/LegacyModelPusher.java +++ b/src/main/java/rs117/hd/renderer/legacy/LegacyModelPusher.java @@ -387,7 +387,7 @@ public void pushModel( sceneContext.modelPusherResults[1] = texturedFaceCount; } - private void getNormalDataForFace(SceneContext sceneContext, Model model, @Nonnull ModelOverride modelOverride, int face) { + private void getNormalDataForFace(LegacySceneContext sceneContext, Model model, @Nonnull ModelOverride modelOverride, int face) { assert packTerrainData(false, 0, WaterType.NONE, 0) == 0; if (modelOverride.flatNormals || !plugin.configPreserveVanillaNormals && model.getFaceColors3()[face] == -1) { Arrays.fill(sceneContext.modelFaceNormals, 0); @@ -424,7 +424,7 @@ private void getNormalDataForFace(SceneContext sceneContext, Model model, @Nonnu @SuppressWarnings({ "ReassignedVariable" }) private int[] getFaceVertices( - SceneContext sceneContext, + LegacySceneContext sceneContext, Tile tile, int uuid, Model model, diff --git a/src/main/java/rs117/hd/renderer/legacy/LegacySceneContext.java b/src/main/java/rs117/hd/renderer/legacy/LegacySceneContext.java index a7ab95a3db..c21beda474 100644 --- a/src/main/java/rs117/hd/renderer/legacy/LegacySceneContext.java +++ b/src/main/java/rs117/hd/renderer/legacy/LegacySceneContext.java @@ -18,6 +18,12 @@ public class LegacySceneContext extends SceneContext { public GpuFloatBuffer stagingBufferUvs; public GpuFloatBuffer stagingBufferNormals; + // Model pusher arrays, to avoid simultaneous usage from different threads + public final int[] modelFaceVertices = new int[12]; + public final float[] modelFaceUvs = new float[12]; + public final float[] modelFaceNormals = new float[12]; + public final int[] modelPusherResults = new int[2]; + public LegacySceneContext( Client client, Scene scene, diff --git a/src/main/java/rs117/hd/renderer/zone/SceneManager.java b/src/main/java/rs117/hd/renderer/zone/SceneManager.java index 1dc0955529..c30e62210d 100644 --- a/src/main/java/rs117/hd/renderer/zone/SceneManager.java +++ b/src/main/java/rs117/hd/renderer/zone/SceneManager.java @@ -40,6 +40,8 @@ import static rs117.hd.HdPlugin.checkGLErrors; import static rs117.hd.renderer.zone.WorldViewContext.DYNAMIC_MODEL_VAO_POOL; import static rs117.hd.renderer.zone.WorldViewContext.DYNAMIC_MODEL_VAO_STAGING_POOL; +import static rs117.hd.utils.HDUtils.hintGC; +import static rs117.hd.utils.HDUtils.isRunningCloseToMemoryCeiling; import static rs117.hd.utils.MathUtils.*; @Slf4j @@ -98,6 +100,11 @@ public class SceneManager { private Zone[][] nextZones; private final List sortedZones = new ArrayList<>(); private boolean reloadRequested; + @Getter + private boolean isExpandedMapLoadingChunksOverridden; + private boolean canRestoreExpandedMapLoadingChunks; + private long memoryState; + private long nextMemoryStateCheck; public boolean isZoneStreamingEnabled() { return plugin.configZoneStreaming; @@ -136,6 +143,7 @@ public WorldViewContext getContext(int worldViewId) { public void initialize(RenderState renderState, UBOWorldViews uboWorldViews) { this.renderState = renderState; this.uboWorldViews = uboWorldViews; + root.isFirstLoad = true; root.initialize(renderState, injector); } @@ -159,12 +167,44 @@ public void destroy() { uboWorldViews = null; } + public boolean isConsistentlyRunningCloseToMemoryCeiling() { + int count = 0; + for(int i = 0; i < 64; i++) { + long mask = 1L << i; + if((memoryState & mask) == mask) + count++; + } + return count > 32; + } + public void update() { assert client.isClientThread(); frameTimer.begin(Timer.UPDATE_AREA_HIDING); updateAreaHiding(); frameTimer.end(Timer.UPDATE_AREA_HIDING); + if(System.currentTimeMillis() > nextMemoryStateCheck) { + if (isRunningCloseToMemoryCeiling(false)) { + memoryState |= 1L << (plugin.frame % 64L); + } else { + memoryState &= ~(1L << (plugin.frame % 64L)); + } + nextMemoryStateCheck = System.currentTimeMillis() + 78; + } + + if(isExpandedMapLoadingChunksOverridden) { + if(canRestoreExpandedMapLoadingChunks) { + isExpandedMapLoadingChunksOverridden = false; + canRestoreExpandedMapLoadingChunks = false; + client.setExpandedMapLoading(plugin.getExpandedMapLoadingChunks()); + log.debug("Restored Expanded Map Loading Chunks to {}...", plugin.getExpandedMapLoadingChunks()); + } + } else if (isConsistentlyRunningCloseToMemoryCeiling() && plugin.getExpandedMapLoadingChunks() > 0) { + log.debug("Disabling Expanded Map Loading Chunks to reduce memory pressure, this will be restored once memory has stabilized"); + client.setExpandedMapLoading(0); + isExpandedMapLoadingChunksOverridden = true; + } + if (reloadRequested && loadingLock.getHoldCount() == 0) { reloadRequested = false; try { @@ -421,6 +461,15 @@ public synchronized void loadScene(WorldView worldView, Scene scene) { nextSceneContext.destroy(); nextSceneContext = null; + // Determine if we're consistently running close to the memory ceiling and force sync loading to reduce chances of OOMing + final boolean canAsyncExecute = !isConsistentlyRunningCloseToMemoryCeiling(); + if(!canAsyncExecute) { + log.debug("Forcing sync load to reduce memory allocation pressure, load will be drastically slower..."); + hintGC(1000); + } else if(isExpandedMapLoadingChunksOverridden) { + canRestoreExpandedMapLoadingChunks = true; + } + nextZones = new Zone[NUM_ZONES][NUM_ZONES]; nextSceneContext = new ZoneSceneContext( client, @@ -448,8 +497,16 @@ public synchronized void loadScene(WorldView worldView, Scene scene) { loadSceneLightsTask.cancel(); calculateRoofChangesTask.cancel(); - generateSceneDataTask.queue(); - loadSceneLightsTask.queue(); + if(root.sceneContext != null) + proceduralGenerator.moveSceneData(nextSceneContext, root.sceneContext); + + generateSceneDataTask + .setExecuteAsync(canAsyncExecute) + .queue(); + + loadSceneLightsTask + .setExecuteAsync(canAsyncExecute) + .queue(); if (nextSceneContext.enableAreaHiding) { assert nextSceneContext.sceneBase != null; @@ -500,7 +557,9 @@ public synchronized void loadScene(WorldView worldView, Scene scene) { } // Queue after ensuring previous scene has been cancelled - calculateRoofChangesTask.queue(); + calculateRoofChangesTask + .setExecuteAsync(canAsyncExecute) + .queue(); final int dx = scene.getBaseX() - prev.getBaseX() >> 3; final int dy = scene.getBaseY() - prev.getBaseY() >> 3; @@ -538,7 +597,7 @@ public synchronized void loadScene(WorldView worldView, Scene scene) { } } - boolean staggerLoad = + boolean staggerLoad = canAsyncExecute && isZoneStreamingEnabled() && !nextSceneContext.isInHouse && root.sceneContext != null && @@ -554,6 +613,7 @@ public synchronized void loadScene(WorldView worldView, Scene scene) { if (!staggerLoad || dist < ZONE_DEFER_DIST_START) { ZoneUploadJob .build(ctx, nextSceneContext, zone, true, x, z) + .setExecuteAsync(canAsyncExecute) .queue(ctx.sceneLoadGroup, generateSceneDataTask); nextSceneContext.totalMapZones++; } else { @@ -579,6 +639,7 @@ public synchronized void loadScene(WorldView worldView, Scene scene) { nextZones[sorted.x][sorted.z] = newZone; ZoneUploadJob .build(ctx, nextSceneContext, newZone, true, sorted.x, sorted.z) + .setExecuteAsync(canAsyncExecute) .queue(ctx.sceneLoadGroup, generateSceneDataTask); } sorted.free(); @@ -612,13 +673,15 @@ public void swapScene(Scene scene) { fishingSpotReplacer.despawnRuneLiteObjects(); npcDisplacementCache.clear(); - boolean isFirst = root.sceneContext == null; - if (!isFirst) + if (root.sceneContext != null) root.sceneContext.destroy(); // Destroy the old context before replacing it // Wait for roof change calculation to complete calculateRoofChangesTask.waitForCompletion(); + if(isConsistentlyRunningCloseToMemoryCeiling()) + hintGC(); + WorldViewContext ctx = root; if (!nextRoofChanges.isEmpty()) { for (int x = 0; x < ctx.sizeX; ++x) { @@ -693,7 +756,8 @@ public void swapScene(Scene scene) { nextZones = null; nextSceneContext = null; - if (isFirst) { + if (root.isFirstLoad) { + root.isFirstLoad = false; root.initBuffers(); // Load all pre-existing sub scenes on the first scene load diff --git a/src/main/java/rs117/hd/renderer/zone/WorldViewContext.java b/src/main/java/rs117/hd/renderer/zone/WorldViewContext.java index a9cdebb4ea..50b3b48d7c 100644 --- a/src/main/java/rs117/hd/renderer/zone/WorldViewContext.java +++ b/src/main/java/rs117/hd/renderer/zone/WorldViewContext.java @@ -61,6 +61,7 @@ public class WorldViewContext { Zone[][] zones; GLBuffer vboM; boolean isLoading = true; + boolean isFirstLoad = true; int minLevel, level, maxLevel; Set hideRoofIds; diff --git a/src/main/java/rs117/hd/renderer/zone/ZoneUploadJob.java b/src/main/java/rs117/hd/renderer/zone/ZoneUploadJob.java index 96d1e3b9d6..8b44288683 100644 --- a/src/main/java/rs117/hd/renderer/zone/ZoneUploadJob.java +++ b/src/main/java/rs117/hd/renderer/zone/ZoneUploadJob.java @@ -150,10 +150,4 @@ public String toString() { x, z ); } - - @Override - @SuppressWarnings("deprecation") - protected void finalize() { - log.debug("ZoneUploadJob finalized, it should have been pooled? - {}", this); - } } diff --git a/src/main/java/rs117/hd/scene/ProceduralGenerator.java b/src/main/java/rs117/hd/scene/ProceduralGenerator.java index 2a3969d99e..ec91acea47 100644 --- a/src/main/java/rs117/hd/scene/ProceduralGenerator.java +++ b/src/main/java/rs117/hd/scene/ProceduralGenerator.java @@ -97,6 +97,41 @@ public void generateSceneData(SceneContext sceneContext) log.debug("-- generateUnderwaterTerrain: {}ms", timerGenerateUnderwaterTerrain); } + public void moveSceneData(SceneContext sceneContext, SceneContext prevSceneContext) { + sceneContext.tileIsWater = prevSceneContext.tileIsWater; + sceneContext.skipTile = prevSceneContext.skipTile; + + for(int i = 0; i < sceneContext.tileIsWater.length; i++) { + for(int k = 0; k < sceneContext.tileIsWater.length; k++) { + Arrays.fill(sceneContext.tileIsWater[i][k], false); + Arrays.fill(sceneContext.skipTile[i][k], false); + } + } + + sceneContext.vertexIsWater = prevSceneContext.vertexIsWater; + sceneContext.vertexIsWater.clear(); + + sceneContext.vertexIsLand = prevSceneContext.vertexIsLand; + sceneContext.vertexIsLand.clear(); + + sceneContext.vertexIsOverlay = prevSceneContext.vertexIsOverlay; + sceneContext.vertexIsOverlay.clear(); + + sceneContext.vertexIsUnderlay = prevSceneContext.vertexIsUnderlay; + sceneContext.vertexIsUnderlay.clear(); + + sceneContext.vertexUnderwaterDepth = prevSceneContext.vertexUnderwaterDepth; + sceneContext.vertexUnderwaterDepth.clear(); + + sceneContext.vertexTerrainNormals = prevSceneContext.vertexTerrainNormals; + sceneContext.vertexTerrainNormals.clear(); + + sceneContext.highPriorityColor = prevSceneContext.highPriorityColor; + sceneContext.highPriorityColor.clear(); + + clearSceneData(prevSceneContext); + } + public void clearSceneData(SceneContext sceneContext) { sceneContext.tileIsWater = null; sceneContext.vertexIsWater = null; @@ -116,17 +151,22 @@ public void clearSceneData(SceneContext sceneContext) { */ private void generateTerrainData(SceneContext sceneContext) { - sceneContext.vertexTerrainColor = new HashMap<>(); + if(sceneContext.vertexTerrainColor == null) + sceneContext.vertexTerrainColor = new HashMap<>(); // used for overriding potentially undesirable vertex colors // for example, colors that aren't supposed to be visible - sceneContext.highPriorityColor = new HashMap<>(); - sceneContext.vertexTerrainTexture = new HashMap<>(); + if(sceneContext.highPriorityColor == null) + sceneContext.highPriorityColor = new HashMap<>(); + if(sceneContext.vertexTerrainTexture == null) + sceneContext.vertexTerrainTexture = new HashMap<>(); // for faces without an overlay is set to true - sceneContext.vertexIsUnderlay = new HashMap<>(); + if(sceneContext.vertexIsUnderlay == null) + sceneContext.vertexIsUnderlay = new HashMap<>(); // for faces with an overlay is set to true // the result of these maps can be used to determine the vertices // between underlays and overlays for custom blending - sceneContext.vertexIsOverlay = new HashMap<>(); + if(sceneContext.vertexIsOverlay == null) + sceneContext.vertexIsOverlay = new HashMap<>(); Tile[][][] tiles = sceneContext.scene.getExtendedTiles(); int sizeX = sceneContext.sizeX; @@ -334,18 +374,23 @@ private void generateUnderwaterTerrain(SceneContext sceneContext) int sizeX = sceneContext.sizeX; int sizeY = sceneContext.sizeZ; // true if a tile contains at least 1 face which qualifies as water - sceneContext.tileIsWater = new boolean[MAX_Z][sizeX][sizeY]; + if(sceneContext.tileIsWater == null || sceneContext.tileIsWater[0].length != sizeX || sceneContext.tileIsWater[0].length != sizeY) + sceneContext.tileIsWater = new boolean[MAX_Z][sizeX][sizeY]; // true if a vertex is part of a face which qualifies as water; non-existent if not - sceneContext.vertexIsWater = new HashMap<>(); + if(sceneContext.vertexIsWater == null) + sceneContext.vertexIsWater = new HashMap<>(); // true if a vertex is part of a face which qualifies as land; non-existent if not // tiles along the shoreline will be true for both vertexIsWater and vertexIsLand - sceneContext.vertexIsLand = new HashMap<>(); + if(sceneContext.vertexIsLand == null) + sceneContext.vertexIsLand = new HashMap<>(); // if true, the tile will be skipped when the scene is drawn // this is due to certain edge cases with water on the same X/Y on different planes - sceneContext.skipTile = new boolean[MAX_Z][sizeX][sizeY]; + if(sceneContext.skipTile == null || sceneContext.skipTile[0].length != sizeX || sceneContext.skipTile[0].length != sizeY) + sceneContext.skipTile = new boolean[MAX_Z][sizeX][sizeY]; // the height adjustment for each vertex, to be applied to the vertex' // real height to create the underwater terrain - sceneContext.vertexUnderwaterDepth = new HashMap<>(); + if(sceneContext.vertexUnderwaterDepth == null) + sceneContext.vertexUnderwaterDepth = new HashMap<>(); // the basic 'levels' of underwater terrain, used to sink terrain based on its distance // from the shore, then used to produce the world-space height offset // 0 = land @@ -685,7 +730,8 @@ else if (tile.getSceneTileModel() != null) */ private void calculateTerrainNormals(SceneContext sceneContext) { - sceneContext.vertexTerrainNormals = new HashMap<>(); + if(sceneContext.vertexTerrainNormals == null) + sceneContext.vertexTerrainNormals = new HashMap<>(); for (Tile[][] plane : sceneContext.scene.getExtendedTiles()) { for (Tile[] column : plane) { diff --git a/src/main/java/rs117/hd/scene/SceneContext.java b/src/main/java/rs117/hd/scene/SceneContext.java index 1852f4c08a..342206866d 100644 --- a/src/main/java/rs117/hd/scene/SceneContext.java +++ b/src/main/java/rs117/hd/scene/SceneContext.java @@ -79,12 +79,6 @@ public class SceneContext { public final HashSet knownProjectiles = new HashSet<>(); public final ArrayList lightSpawnsToHandleOnClientThread = new ArrayList<>(); - // Model pusher arrays, to avoid simultaneous usage from different threads - public final int[] modelFaceVertices = new int[12]; - public final float[] modelFaceUvs = new float[12]; - public final float[] modelFaceNormals = new float[12]; - public final int[] modelPusherResults = new int[2]; - public SceneContext(Client client, Scene scene, int expandedMapLoadingChunks) { this.client = client; this.scene = scene; diff --git a/src/main/java/rs117/hd/utils/HDUtils.java b/src/main/java/rs117/hd/utils/HDUtils.java index 0d9b1da7fe..8dbaa5e2da 100644 --- a/src/main/java/rs117/hd/utils/HDUtils.java +++ b/src/main/java/rs117/hd/utils/HDUtils.java @@ -27,6 +27,7 @@ import java.awt.Canvas; import java.awt.Container; import java.awt.Frame; +import java.util.concurrent.locks.LockSupport; import javax.annotation.Nullable; import javax.inject.Singleton; import javax.swing.JFrame; @@ -508,4 +509,78 @@ public static JFrame getJFrame(Canvas canvas) { return null; } + + public static boolean isRunningCloseToMemoryCeiling(boolean printStats) { + final Runtime RT = Runtime.getRuntime(); + + final long free = RT.freeMemory(); + final long total = RT.totalMemory(); + final long max = RT.maxMemory(); + + final long used = total - free; + final double heapUsage = (double) used / max; + final long expandable = max - total; + + final boolean heapNearlyFull = heapUsage > 0.80; + final boolean littleExpandable = expandable < (max * 0.10); + final boolean lowFreeInHeap = free < (total * 0.15); + + if(heapNearlyFull && (littleExpandable || lowFreeInHeap)) { + if(printStats) { + log.debug( + "Memory Stats: heapUsage={}%, used={}MB, free={}MB, total={}MB, max={}MB, expandable={}MB, littleExpandable={}, lowFreeInHeap={}", + String.format("%.1f", heapUsage * 100), + used / (1024 * 1024), + free / (1024 * 1024), + total / (1024 * 1024), + max / (1024 * 1024), + expandable / (1024 * 1024), + littleExpandable, + lowFreeInHeap + ); + } + return true; + } + return false; + } + + // Heuristically hint the JVM to run GC when heap usage is high and expansion/free space is limited. + // Uses runtime memory stats to approximate allocation pressure (not exact fragmentation). + public static boolean hintGC(long maxMillis) { + if (!isRunningCloseToMemoryCeiling(true)) + return false; + + final Runtime RT = Runtime.getRuntime(); + final long usedBefore = RT.totalMemory() - RT.freeMemory(); + final long startTime = System.nanoTime(); + final long deadline = startTime + (maxMillis * 1_000_000L); + + int iterations = 0; + while (System.nanoTime() < deadline) { + System.gc(); + System.runFinalization(); + System.gc(); + iterations++; + + if (!isRunningCloseToMemoryCeiling(true)) { + final long usedAfter = RT.totalMemory() - RT.freeMemory(); + log.debug( + "GC reclaimed {}MB of memory (After {} iterations, {}ms elapsed)", + (usedBefore - usedAfter) / (1024 * 1024), + iterations, + (System.nanoTime() - startTime) / 1_000_000L + ); + return false; + } + + LockSupport.parkNanos(1_000_000L); + } + + final long usedAfter = RT.totalMemory() - RT.freeMemory(); + log.debug("GC reclaimed {}MB of memory (timeout reached)", (usedBefore - usedAfter) / (1024 * 1024)); + + return true; + } + + public static boolean hintGC() { return hintGC(1); } }