diff --git a/src/main/java/rs117/hd/HdPlugin.java b/src/main/java/rs117/hd/HdPlugin.java index cb715fd25a..b52ddf54f4 100644 --- a/src/main/java/rs117/hd/HdPlugin.java +++ b/src/main/java/rs117/hd/HdPlugin.java @@ -93,11 +93,11 @@ import rs117.hd.opengl.uniforms.UBOGlobal; import rs117.hd.opengl.uniforms.UBOLights; import rs117.hd.opengl.uniforms.UBOUI; -import rs117.hd.overlays.FrameTimer; import rs117.hd.overlays.GammaCalibrationOverlay; import rs117.hd.overlays.ShadowMapOverlay; import rs117.hd.overlays.TiledLightingOverlay; -import rs117.hd.overlays.Timer; +import rs117.hd.profiling.Profiler; +import rs117.hd.profiling.Timer; import rs117.hd.renderer.Renderer; import rs117.hd.renderer.legacy.LegacyRenderer; import rs117.hd.renderer.zone.SceneManager; @@ -294,7 +294,7 @@ public class HdPlugin extends Plugin { private DeveloperTools developerTools; @Inject - private FrameTimer frameTimer; + private Profiler profiler; @Inject private UIShaderProgram uiProgram; @@ -572,6 +572,8 @@ protected void startUp() { String glVendor = Objects.requireNonNullElse(glGetString(GL_VENDOR), "Unknown"); var runtime = Runtime.getRuntime(); + boolean supportsThreadAllocationTracking = HDUtils.setupThreadAllocatedBytesMonitoring(); + APPLE = osType == OSType.MacOS; APPLE_ARM = APPLE && osArch.equals("aarch64"); AMD_GPU = glRenderer.contains("AMD") || glRenderer.contains("Radeon") || glVendor.contains("ATI"); @@ -582,19 +584,20 @@ protected void startUp() { SUPPORTS_STORAGE_BUFFERS = GL_CAPS.GL_ARB_buffer_storage && !DEBUG_MAC_OS && config.storageBuffers().get(!INTEL_GPU); log.info("Starting 117 HD... (count: {})", startupCount); - log.info("Renderer: {}", rendererClass.getSimpleName()); - log.info("rlawt version: {}", rlawtVersion); - log.info("LWJGL Version: {}", Version.getVersion()); - log.info("Java version: {} ({})", javaVmName, javaVersion); - log.info("Java memory limit: {} (free: {})", formatBytes(runtime.maxMemory()), formatBytes(runtime.freeMemory())); - log.info("Operating system: {} {} ({}-bit {})", osType, osVersion, wordSize, osArch); - log.info("CPU: {} ({} threads)", HDUtils.getCpuName(), runtime.availableProcessors()); - log.info("Memory: {}", formatBytes(HDUtils.getTotalSystemMemory())); - log.info("GPU: {} ({})", glRenderer, glVendor); - log.info("GPU driver: {}", glGetString(GL_VERSION)); - log.info("Indirect draw: {}", SUPPORTS_INDIRECT_DRAW); - log.info("Storage buffers: {}", SUPPORTS_STORAGE_BUFFERS); - log.info("Low memory mode: {}", useLowMemoryMode); + log.info("Renderer: {}", rendererClass.getSimpleName()); + log.info("rlawt version: {}", rlawtVersion); + log.info("LWJGL Version: {}", Version.getVersion()); + log.info("Java version: {} ({})", javaVmName, javaVersion); + log.info("Java memory limit: {} (free: {})", formatBytes(runtime.maxMemory()), formatBytes(runtime.freeMemory())); + log.info("Operating system: {} {} ({}-bit {})", osType, osVersion, wordSize, osArch); + log.info("CPU: {} ({} threads)", HDUtils.getCpuName(), runtime.availableProcessors()); + log.info("Memory: {}", formatBytes(HDUtils.getTotalSystemMemory())); + log.info("GPU: {} ({})", glRenderer, glVendor); + log.info("GPU driver: {}", glGetString(GL_VERSION)); + log.info("Indirect draw: {}", SUPPORTS_INDIRECT_DRAW); + log.info("Storage buffers: {}", SUPPORTS_STORAGE_BUFFERS); + log.info("Allocation Tracking: {}", supportsThreadAllocationTracking); + log.info("Low memory mode: {}", useLowMemoryMode); renderer = injector.getInstance(rendererClass); @@ -1507,10 +1510,10 @@ public void prepareInterfaceTexture() { uiWidth = bufferProvider.getWidth(); uiHeight = bufferProvider.getHeight(); - frameTimer.begin(Timer.MAP_UI_BUFFER); + profiler.begin(Timer.MAP_UI_BUFFER); final GLBuffer pbo = pboUi[frame % 3]; pbo.map(MAP_WRITE, 0, uiWidth * uiHeight * 4L); - frameTimer.end(Timer.MAP_UI_BUFFER); + profiler.end(Timer.MAP_UI_BUFFER); if (!pbo.isMapped()) { log.error("Unable to map interface PBO. Skipping UI..."); } else if (uiWidth > uiResolution[0] || uiHeight > uiResolution[1]) { @@ -1520,9 +1523,9 @@ public void prepareInterfaceTexture() { .build( "AsyncUICopy", t -> { - long start = System.nanoTime(); + long timestamp = profiler.getTimeStamp(); pbo.mapped().intView().put(pixels, 0, uiWidth * uiHeight); - frameTimer.add(Timer.COPY_UI_ASYNC, System.nanoTime() - start); + profiler.add(Timer.COPY_UI_ASYNC, timestamp); } ) .setExecuteAsync(!isPowerSaving) @@ -1539,7 +1542,7 @@ public void drawUi(int overlayColor) { if (client.getGameState().getState() < GameState.LOADING.getState()) overlayColor = 0; - frameTimer.begin(Timer.RENDER_UI); + profiler.begin(Timer.RENDER_UI); glBindFramebuffer(GL_FRAMEBUFFER, awtContext.getFramebuffer(false)); // Disable alpha writes, just in case the default FBO has an alpha channel @@ -1564,19 +1567,19 @@ public void drawUi(int overlayColor) { glBindTexture(GL_TEXTURE_2D, texUi); if (uiCopyJob != null) { - frameTimer.begin(Timer.COPY_UI); + profiler.begin(Timer.COPY_UI); uiCopyJob.waitForCompletion(true); uiCopyJob = null; - frameTimer.end(Timer.COPY_UI); + profiler.end(Timer.COPY_UI); - frameTimer.begin(Timer.UPLOAD_UI); + profiler.begin(Timer.UPLOAD_UI); final GLBuffer pbo = pboUi[frame % 3]; pbo.unmap(); pbo.bind(); glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, uiWidth, uiHeight, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, 0); pbo.unbind(); - frameTimer.end(Timer.UPLOAD_UI); + profiler.end(Timer.UPLOAD_UI); } glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, function); @@ -1595,7 +1598,7 @@ public void drawUi(int overlayColor) { glDisable(GL_BLEND); glColorMask(true, true, true, true); - frameTimer.end(Timer.RENDER_UI); + profiler.end(Timer.RENDER_UI); } /** @@ -1922,7 +1925,7 @@ public void processPendingConfigChanges() { sceneManager.getLoadingLock().unlock(); log.trace("loadingLock unlocked - holdCount: {}", sceneManager.getLoadingLock().getHoldCount()); pendingConfigChanges.clear(); - frameTimer.reset(); + profiler.reset(); } }); } @@ -1932,7 +1935,7 @@ public void setupSyncMode() { boolean unlockFps = config.unlockFps(); HdPluginConfig.SyncMode syncMode = unlockFps ? config.syncMode() : HdPluginConfig.SyncMode.OFF; - if (frameTimer.isActive()) { + if (profiler.isActive()) { unlockFps = true; syncMode = SyncMode.OFF; } diff --git a/src/main/java/rs117/hd/HdPluginConfig.java b/src/main/java/rs117/hd/HdPluginConfig.java index f79b21b215..f68059b398 100644 --- a/src/main/java/rs117/hd/HdPluginConfig.java +++ b/src/main/java/rs117/hd/HdPluginConfig.java @@ -1253,6 +1253,72 @@ default boolean multithreadedModelProcessing() { /*====== Internal settings ======*/ + String KEY_FRAME_TIMER_OVERLAY_ENABLED = "frameTimerOverlayEnabled"; + @ConfigItem(keyName = KEY_FRAME_TIMER_OVERLAY_ENABLED, hidden = true, name = "", description = "") + default boolean frameTimerOverlayEnabled() { + return false; + } + + String KEY_FRAME_TIMER_GRAPH_ENABLED = "frameTimerGraphEnabled"; + @ConfigItem(keyName = KEY_FRAME_TIMER_GRAPH_ENABLED, hidden = true, name = "", description = "") + default boolean frameTimerGraphEnabled() { + return false; + } + + String KEY_FRAME_TIMER_HIDDEN_GRAPHS = "frameTimerHiddenGraphs"; + @ConfigItem(keyName = KEY_FRAME_TIMER_HIDDEN_GRAPHS, hidden = true, name = "", description = "") + default String frameTimerHiddenGraphs() { + return ""; + } + + String KEY_FRAME_TIMER_SELECTED_TAB = "frameTimerSelectedTab"; + @ConfigItem(keyName = KEY_FRAME_TIMER_SELECTED_TAB, hidden = true, name = "", description = "") + default String frameTimerSelectedTab() { + return "ALL"; + } + + String KEY_FRAME_TIMER_HIDDEN_TABS = "frameTimerHiddenTabs"; + @ConfigItem(keyName = KEY_FRAME_TIMER_HIDDEN_TABS, hidden = true, name = "", description = "") + default String frameTimerHiddenTabs() { + return ""; + } + + String KEY_FRAME_TIMER_DETACHED_TABS = "frameTimerDetachedTabs"; + @ConfigItem(keyName = KEY_FRAME_TIMER_DETACHED_TABS, hidden = true, name = "", description = "") + default String frameTimerDetachedTabs() { + return ""; + } + + String KEY_FRAME_TIMER_SETTINGS_DETACHED = "frameTimerSettingsDetached"; + @ConfigItem(keyName = KEY_FRAME_TIMER_SETTINGS_DETACHED, hidden = true, name = "", description = "") + default boolean frameTimerSettingsDetached() { + return false; + } + + String KEY_FRAME_TIMER_GRAPH_DETACHED = "frameTimerGraphDetached"; + @ConfigItem(keyName = KEY_FRAME_TIMER_GRAPH_DETACHED, hidden = true, name = "", description = "") + default boolean frameTimerGraphDetached() { + return false; + } + + String KEY_FRAME_TIMER_GRAPH_WIDTH = "frameTimerGraphWidth"; + @ConfigItem(keyName = KEY_FRAME_TIMER_GRAPH_WIDTH, hidden = true, name = "", description = "") + default int frameTimerGraphWidth() { + return 800; + } + + String KEY_FRAME_TIMER_GRAPH_HEIGHT = "frameTimerGraphHeight"; + @ConfigItem(keyName = KEY_FRAME_TIMER_GRAPH_HEIGHT, hidden = true, name = "", description = "") + default int frameTimerGraphHeight() { + return 200; + } + + String KEY_FRAME_TIMER_MEMORY_GRAPH_HEIGHT = "frameTimerMemoryGraphHeight"; + @ConfigItem(keyName = KEY_FRAME_TIMER_MEMORY_GRAPH_HEIGHT, hidden = true, name = "", description = "") + default int frameTimerMemoryGraphHeight() { + return 75; + } + @ConfigItem(keyName = "pluginUpdateMessage", hidden = true, name = "", description = "") void setPluginUpdateMessage(int version); @ConfigItem(keyName = "pluginUpdateMessage", hidden = true, name = "", description = "") diff --git a/src/main/java/rs117/hd/overlays/FrameTimerOverlay.java b/src/main/java/rs117/hd/overlays/FrameTimerOverlay.java deleted file mode 100644 index 452f3ea6e9..0000000000 --- a/src/main/java/rs117/hd/overlays/FrameTimerOverlay.java +++ /dev/null @@ -1,322 +0,0 @@ -package rs117.hd.overlays; - -import com.google.inject.Inject; -import com.google.inject.Singleton; -import java.awt.Dimension; -import java.awt.Graphics2D; -import java.util.ArrayDeque; -import java.util.Arrays; -import java.util.Formatter; -import java.util.HashMap; -import java.util.Map; -import net.runelite.client.ui.FontManager; -import net.runelite.client.ui.overlay.OverlayLayer; -import net.runelite.client.ui.overlay.OverlayManager; -import net.runelite.client.ui.overlay.OverlayPanel; -import net.runelite.client.ui.overlay.OverlayPosition; -import net.runelite.client.ui.overlay.components.LineComponent; -import net.runelite.client.ui.overlay.components.TitleComponent; -import rs117.hd.HdPlugin; -import rs117.hd.renderer.zone.SceneManager; -import rs117.hd.renderer.zone.WorldViewContext; -import rs117.hd.renderer.zone.ZoneRenderer; -import rs117.hd.utils.FrameTimingsRecorder; -import rs117.hd.utils.NpcDisplacementCache; -import rs117.hd.utils.collections.PooledArrayType; -import rs117.hd.utils.jobs.JobSystem; - -import static rs117.hd.renderer.zone.SceneManager.MAX_WORLDVIEWS; -import static rs117.hd.utils.MathUtils.*; - -@Singleton -public class FrameTimerOverlay extends OverlayPanel implements FrameTimer.Listener { - @Inject - private OverlayManager overlayManager; - - @Inject - private HdPlugin plugin; - - @Inject - private FrameTimer frameTimer; - - @Inject - private FrameTimingsRecorder frameTimingsRecorder; - - @Inject - private NpcDisplacementCache npcDisplacementCache; - - @Inject - private JobSystem jobSystem; - - @Inject - private SceneManager sceneManager; - - private final ArrayDeque frames = new ArrayDeque<>(); - private final long[] timings = new long[Timer.TIMERS.length]; - private float cpuLoad; - private final Map componentMap = new HashMap<>(); - private final StringBuilder sb = new StringBuilder(); - private final Formatter formatter = new Formatter(sb); - - @Inject - public FrameTimerOverlay(HdPlugin plugin) { - super(plugin); - setLayer(OverlayLayer.ABOVE_SCENE); - setPosition(OverlayPosition.TOP_RIGHT); - panelComponent.setPreferredSize(new Dimension(215, 200)); - } - - public void setActive(boolean activate) { - if (activate) { - frameTimer.addTimingsListener(this); - overlayManager.add(this); - } else { - frameTimer.removeTimingsListener(this); - overlayManager.remove(this); - frames.clear(); - } - } - - private String format(String format, Object... args) { - sb.setLength(0); - formatter.format(format, args); - return sb.toString(); - } - - @Override - public void onFrameCompletion(FrameTimings timings) { - long now = System.currentTimeMillis(); - while (!frames.isEmpty()) { - if (now - frames.peekFirst().frameTimestamp < 10e3) // remove older entries - break; - frames.removeFirst(); - } - frames.addLast(timings); - } - - @Override - public Dimension render(Graphics2D g) { - long time = System.nanoTime(); - var boldFont = FontManager.getRunescapeBoldFont(); - - var children = panelComponent.getChildren(); - if (!getAverageTimings()) { - children.add(TitleComponent.builder() - .text("Waiting for data...") - .build()); - } else { - long cpuTime = timings[Timer.DRAW_FRAME.ordinal()]; - long asyncCpuTime = 0; - addTiming("CPU", cpuTime, true); - for (var t : Timer.TIMERS) { - if (t.isCpuTimer() && t != Timer.DRAW_FRAME) - addTiming(t, timings); - if (t.isAsyncCpuTimer()) - asyncCpuTime += timings[t.ordinal()]; - } - - addTiming("Async", asyncCpuTime, true); - for (var t : Timer.TIMERS) - if (t.isAsyncCpuTimer()) - addTiming(t, timings); - - if (cpuLoad > 0) { - children.add(LineComponent.builder() - .left("CPU Load:") - .right((int) (cpuLoad * 100) + "%") - .build()); - } - - long gpuTime = timings[Timer.RENDER_FRAME.ordinal()]; - addTiming("GPU", gpuTime, true); - for (var t : Timer.TIMERS) - if (t.isGpuTimer() && t != Timer.RENDER_FRAME) - addTiming(t, timings); - - children.add(LineComponent.builder() - .leftFont(boldFont) - .left("Estimated bottleneck:") - .rightFont(boldFont) - .right(cpuTime > gpuTime ? "CPU" : "GPU") - .build()); - - children.add(LineComponent.builder() - .leftFont(boldFont) - .left("Estimated FPS:") - .rightFont(boldFont) - .right(format("%.1f FPS", 1e9 / max(cpuTime, gpuTime))) - .build()); - - children.add(LineComponent.builder() - .left("Error compensation:") - .right(format("%d ns", frameTimer.errorCompensation)) - .build()); - - children.add(LineComponent.builder() - .left("Pooled array size:") - .right(formatBytes(PooledArrayType.getCurrentTotalCacheSize())) - .build()); - - children.add(LineComponent.builder() - .left("Garbage collection count:") - .right(String.valueOf(plugin.getGarbageCollectionCount())) - .build()); - - children.add(LineComponent.builder() - .left("Power saving mode:") - .right(plugin.isPowerSaving ? "ON" : "OFF") - .build()); - - children.add(LineComponent.builder() - .leftFont(boldFont) - .left("Scene stats:") - .build()); - - if (plugin.getSceneContext() != null) { - var sceneContext = plugin.getSceneContext(); - children.add(LineComponent.builder() - .left("Lights:") - .right(format("%d/%d", sceneContext.numVisibleLights, sceneContext.lights.size())) - .build()); - } - - if (plugin.renderer instanceof ZoneRenderer) { - children.add(LineComponent.builder() - .left("Dynamic renderables:") - .right(String.valueOf(plugin.getDrawnDynamicRenderableCount())) - .build()); - - children.add(LineComponent.builder() - .left("Temp renderables:") - .right(String.valueOf(plugin.getDrawnTempRenderableCount())) - .build()); - } else { - children.add(LineComponent.builder() - .left("Tiles:") - .right(String.valueOf(plugin.getDrawnTileCount())) - .build()); - - children.add(LineComponent.builder() - .left("Static renderables:") - .right(String.valueOf(plugin.getDrawnStaticRenderableCount())) - .build()); - - children.add(LineComponent.builder() - .left("Dynamic renderables:") - .right(String.valueOf(plugin.getDrawnDynamicRenderableCount())) - .build()); - - children.add(LineComponent.builder() - .left("NPC displacement cache size:") - .right(String.valueOf(npcDisplacementCache.size())) - .build()); - } - - children.add(LineComponent.builder() - .leftFont(boldFont) - .left("Streaming Stats:") - .build()); - - WorldViewContext root = sceneManager.getRoot(); - addTiming("Root Scene Load", root.loadTime, false); - addTiming("Root Scene Upload", root.uploadTime, false); - addTiming("Root Scene Swap", root.sceneSwapTime, false); - - // TODO: Maybe this should be calculated somewhere else - int subSceneCount = 0; - long subSceneLoadTime = 0; - long subSceneUploadTime = 0; - long subSceneSwapTime = 0; - - for (int worldViewId = 0; worldViewId < MAX_WORLDVIEWS; worldViewId++) { - WorldViewContext subscene = sceneManager.getContext(worldViewId); - if (subscene != null) { - subSceneCount++; - subSceneLoadTime += subscene.loadTime; - subSceneUploadTime += subscene.uploadTime; - subSceneSwapTime += subscene.sceneSwapTime; - } - } - - if (subSceneCount > 0) { - addTiming("Avg SubScene Load", subSceneLoadTime / subSceneCount, false); - addTiming("Avg SubScene Upload", subSceneUploadTime / subSceneCount, false); - addTiming("Avg SubScene Swap", subSceneSwapTime / subSceneCount, false); - } - - children.add(LineComponent.builder() - .left("Sub Scene Count:") - .right(String.valueOf(subSceneCount)) - .build()); - - - children.add(LineComponent.builder() - .left("Streaming Zones:") - .right(String.valueOf(jobSystem.getWorkQueueSize())) - .build()); - - if (frameTimingsRecorder.isCapturingSnapshot()) - children.add(LineComponent.builder() - .leftFont(boldFont) - .left("Capturing Snapshot...") - .rightFont(boldFont) - .right(format("%d%%", frameTimingsRecorder.getProgressPercentage())) - .build()); - } - - var result = super.render(g); - frameTimer.cumulativeError += System.nanoTime() - time; - return result; - } - - private boolean getAverageTimings() { - if (frames.isEmpty()) - return false; - - Arrays.fill(timings, 0); - cpuLoad = 0; - for (var frame : frames) { - for (int i = 0; i < frame.timers.length; i++) - timings[i] += frame.timers[i]; - cpuLoad += frame.cpuLoad; - } - - for (int i = 0; i < timings.length; i++) - timings[i] = max(0, timings[i] / frames.size()); - cpuLoad /= frames.size(); - - return true; - } - - private void addTiming(Timer timer, long[] timings) { - addTiming(timer.name, timings[timer.ordinal()], false); - } - - private void addTiming(String name, long nanos, boolean bold) { - if (nanos == 0) - return; - - // Round timers to zero if they are less than a microsecond off - String result = "~0 ms"; - if (abs(nanos) > 1e3) { - sb.setLength(0); - result = sb.append(round(nanos / 1e3) / 1e3).append(" ms").toString(); - } - - LineComponent component = componentMap.get(name); - if (component == null) { - var font = bold ? FontManager.getRunescapeBoldFont() : FontManager.getRunescapeFont(); - component = LineComponent.builder() - .left(name + ":") - .leftFont(font) - .right(result) - .rightFont(font) - .build(); - componentMap.put(name, component); - } else { - component.setRight(result); - } - - panelComponent.getChildren().add(component); - } -} diff --git a/src/main/java/rs117/hd/overlays/FrameTimings.java b/src/main/java/rs117/hd/overlays/FrameTimings.java deleted file mode 100644 index c56aa0f55b..0000000000 --- a/src/main/java/rs117/hd/overlays/FrameTimings.java +++ /dev/null @@ -1,15 +0,0 @@ -package rs117.hd.overlays; - -import java.util.Arrays; - -public class FrameTimings { - public final long frameTimestamp; - public final long[] timers; - public final float cpuLoad; - - public FrameTimings(long frameTimestamp, long[] timers, float cpuLoad) { - this.frameTimestamp = frameTimestamp; - this.timers = Arrays.copyOf(timers, timers.length); - this.cpuLoad = cpuLoad; - } -} diff --git a/src/main/java/rs117/hd/overlays/HDOverlayPanel.java b/src/main/java/rs117/hd/overlays/HDOverlayPanel.java new file mode 100644 index 0000000000..277d628bc4 --- /dev/null +++ b/src/main/java/rs117/hd/overlays/HDOverlayPanel.java @@ -0,0 +1,43 @@ +package rs117.hd.overlays; + +import java.awt.Dimension; +import java.awt.Graphics2D; +import javax.inject.Inject; +import net.runelite.client.ui.overlay.OverlayPanel; +import rs117.hd.HdPlugin; +import rs117.hd.profiling.Profiler; +import rs117.hd.profiling.Timer; + +public abstract class HDOverlayPanel extends OverlayPanel { + + @Inject + private Profiler profiler; + + private final boolean[] pausedTimers = new boolean[Timer.TIMERS.length]; + + @Inject + public HDOverlayPanel(HdPlugin plugin) { + super(plugin); + } + + public Dimension onRender(final Graphics2D graphics) { + return super.render(graphics); + } + + public Dimension render(final Graphics2D graphics) { + for(int i = 0; i < pausedTimers.length; i++) { + final Timer timer = Timer.TIMERS[i]; + if(timer.isCpuTimer()) + pausedTimers[i] = profiler.end(Timer.TIMERS[i]); + } + try { + return onRender(graphics); + }finally { + for(int i = 0; i < pausedTimers.length; i++) { + final Timer timer = Timer.TIMERS[i]; + if(timer.isAsyncCpuTimer() && pausedTimers[i]) + profiler.begin(timer); + } + } + } +} diff --git a/src/main/java/rs117/hd/overlays/ProfilerGraphFrame.java b/src/main/java/rs117/hd/overlays/ProfilerGraphFrame.java new file mode 100644 index 0000000000..47631b4d51 --- /dev/null +++ b/src/main/java/rs117/hd/overlays/ProfilerGraphFrame.java @@ -0,0 +1,489 @@ +package rs117.hd.overlays; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Cursor; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.RenderingHints; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nullable; +import javax.swing.BorderFactory; +import javax.swing.JFrame; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTabbedPane; +import javax.swing.SwingUtilities; +import javax.swing.WindowConstants; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.Client; +import rs117.hd.HdPlugin; +import rs117.hd.overlays.flame.ProfilerFlamePanel; +import rs117.hd.overlays.components.GraphComponent; +import rs117.hd.profiling.ProfileSample; +import rs117.hd.profiling.ProfileSampleStore; +import rs117.hd.profiling.Timer; + +import static rs117.hd.utils.ResourcePath.path; + +@Slf4j +@Singleton +public class ProfilerGraphFrame { + private static final Color PANEL_BACKGROUND = new Color(30, 30, 30); + private static final Color LEGEND_BACKGROUND = new Color(24, 24, 24); + private static final Color LEGEND_BORDER = new Color(60, 60, 60); + private static final Color LEGEND_TEXT = new Color(220, 220, 220); + private static final Color LEGEND_HOVER_BG = new Color(50, 50, 50); + + private static final int PANEL_PADDING = 8; + private static final int LEGEND_WIDTH = 200; + private static final int LEGEND_SWATCH = 12; + private static final int LEGEND_ROW_HEIGHT = 20; + private static final int LEGEND_PADDING = 10; + private static final int REPAINT_MS = 33; + private static final int FLAME_REFRESH_MS = 250; + + @Inject + private Client client; + + @Inject + private HdPlugin plugin; + + @Inject + private ProfileSampleStore profileSampleStore; + + @Inject + private ProfilerUI profilerUI; + + @Inject + private ProfilerOverlay profilerOverlay; + + private ProfilerGraphs graphs; + + private JFrame frame; + private GraphPanel graphPanel; + private LegendPanel legendPanel; + private ProfilerFlamePanel flamePanel; + private javax.swing.Timer repaintTimer; + private long lastFlameRefreshMs; + + @Nullable + private Object hoveredLegendKey; + + @Getter + private boolean active; + + private GraphComponent dragGraph; + private Point dragStartPoint; + private boolean closingToDock; + + private ProfilerGraphs graphs() { + if (graphs == null) + graphs = new ProfilerGraphs(profilerUI); + return graphs; + } + + public void setActive(boolean activate) { + if (active == activate) + return; + + active = activate; + SwingUtilities.invokeLater(() -> { + if (activate) + showFrame(); + else + hideFrame(); + }); + } + + private void showFrame() { + if (frame == null) + createFrame(); + + ensureGraphs(); + applyGraphSizes(); + frame.pack(); + + if (!frame.isVisible()) { + frame.setLocationRelativeTo(client.getCanvas()); + JFrame runeLiteWindow = plugin.clientJFrame; + if (runeLiteWindow != null && runeLiteWindow.isAlwaysOnTop()) + frame.setAlwaysOnTop(true); + frame.setVisible(true); + } + + if (repaintTimer == null) { + repaintTimer = new javax.swing.Timer(REPAINT_MS, e -> { + syncCrossHighlight(); + if (graphPanel != null) + graphPanel.repaint(); + if (legendPanel != null) + legendPanel.repaint(); + if (flamePanel != null) { + long now = System.currentTimeMillis(); + if (now - lastFlameRefreshMs >= FLAME_REFRESH_MS) { + lastFlameRefreshMs = now; + flamePanel.refresh(); + } + } + }); + repaintTimer.start(); + } else if (!repaintTimer.isRunning()) { + repaintTimer.start(); + } + } + + private void hideFrame() { + if (repaintTimer != null) + repaintTimer.stop(); + + if (frame != null && frame.isVisible()) { + closingToDock = true; + frame.setVisible(false); + closingToDock = false; + } + + dragGraph = null; + dragStartPoint = null; + hoveredLegendKey = null; + } + + private void createFrame() { + frame = new JFrame("117 HD Graphs"); + frame.setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE); + frame.addWindowListener(new WindowAdapter() { + @Override + public void windowClosing(WindowEvent e) { + if (closingToDock) + return; + profilerUI.dockGraphs(); + } + }); + + try { + BufferedImage logo = path(HdPlugin.class, "logo.png").loadImage(); + frame.setIconImage(logo); + } catch (IOException ex) { + log.debug("Unable to load HD logo for graph window", ex); + } + + graphPanel = new GraphPanel(); + legendPanel = new LegendPanel(); + + JPanel graphsContent = new JPanel(new BorderLayout()); + graphsContent.setBackground(PANEL_BACKGROUND); + graphsContent.add(graphPanel, BorderLayout.CENTER); + + JScrollPane legendScroll = new JScrollPane( + legendPanel, + JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED, + JScrollPane.HORIZONTAL_SCROLLBAR_NEVER + ); + legendScroll.setBorder(BorderFactory.createMatteBorder(0, 0, 0, 1, LEGEND_BORDER)); + legendScroll.getViewport().setBackground(LEGEND_BACKGROUND); + legendScroll.setPreferredSize(new Dimension(LEGEND_WIDTH, 200)); + graphsContent.add(legendScroll, BorderLayout.WEST); + + flamePanel = new ProfilerFlamePanel(() -> new ArrayList<>(profileSampleStore.getFrames())); + + JTabbedPane tabs = new JTabbedPane(); + tabs.setBackground(PANEL_BACKGROUND); + tabs.setForeground(LEGEND_TEXT); + tabs.addTab("Graphs", graphsContent); + tabs.addTab("Flame graph", flamePanel); + + frame.setContentPane(tabs); + frame.setMinimumSize(new Dimension( + GraphComponent.MIN_GRAPH_WIDTH + LEGEND_WIDTH + PANEL_PADDING * 2 + + GraphComponent.MARGIN_LEFT + GraphComponent.MARGIN_RIGHT, + 280 + )); + frame.setResizable(true); + } + + private Object resolveHoveredKey() { + if (hoveredLegendKey != null) + return hoveredLegendKey; + if (flamePanel != null) { + Object flameHighlight = flamePanel.getHighlightKey(); + if (flameHighlight != null) + return flameHighlight; + } + return profilerOverlay.getHoveredTimer(); + } + + private void syncCrossHighlight() { + if (flamePanel == null) + return; + Object live = hoveredLegendKey != null ? hoveredLegendKey : profilerOverlay.getHoveredTimer(); + flamePanel.setLiveHoverKey(live); + } + + private boolean isKeyActive(@Nullable Object key) { + if (key == null) + return false; + if (key.equals(hoveredLegendKey)) + return true; + Object flame = flamePanel != null ? flamePanel.getHighlightKey() : null; + if (flame != null && (key.equals(flame) || namesMatch(key, flame))) + return true; + Object overlay = profilerOverlay.getHoveredTimer(); + return overlay != null && (key.equals(overlay) || namesMatch(key, overlay)); + } + + private static boolean namesMatch(Object a, Object b) { + String na = a instanceof Timer ? ((Timer) a).name : String.valueOf(a); + String nb = b instanceof Timer ? ((Timer) b).name : String.valueOf(b); + return na.equalsIgnoreCase(nb); + } + + private void ensureGraphs() { + ProfilerGraphs g = graphs(); + if (!g.isEmpty()) + return; + g.create( + this::resolveHoveredKey, + () -> graphPanel == null ? null : graphPanel.getMousePositionInPanel() + ); + } + + private void applyGraphSizes() { + Dimension preferred = graphs().applyPanelSizes( + graphPanel != null ? Math.max(1, graphPanel.getWidth()) : 0, + graphPanel != null ? Math.max(1, graphPanel.getHeight()) : 0, + PANEL_PADDING, + profilerUI.getGraphPlotWidth(), + profilerUI.getGraphPlotHeight(), + profilerUI.getMemoryGraphPlotHeight() + ); + if (graphPanel != null) + graphPanel.setPreferredSize(preferred); + } + + private class GraphPanel extends JPanel { + private Point lastMouse; + + GraphPanel() { + setBackground(PANEL_BACKGROUND); + setOpaque(true); + + MouseAdapter mouse = new MouseAdapter() { + @Override + public void mousePressed(MouseEvent e) { + if (e.getButton() != MouseEvent.BUTTON1) + return; + lastMouse = e.getPoint(); + GraphComponent hit = graphs().findAtLocal(e.getX(), e.getY()); + if (hit != null) { + dragGraph = hit; + dragStartPoint = e.getPoint(); + } + } + + @Override + public void mouseDragged(MouseEvent e) { + lastMouse = e.getPoint(); + if (dragGraph != null) + dragGraph.setSelectionFromPoints(dragStartPoint, e.getPoint()); + repaint(); + } + + @Override + public void mouseReleased(MouseEvent e) { + lastMouse = e.getPoint(); + if (dragGraph != null) { + if (dragStartPoint.distance(e.getPoint()) <= 0.01) + dragGraph.clearSelection(); + else + dragGraph.setSelectionFromPoints(dragStartPoint, e.getPoint()); + dragGraph = null; + dragStartPoint = null; + } + repaint(); + } + + @Override + public void mouseMoved(MouseEvent e) { + lastMouse = e.getPoint(); + repaint(); + } + + @Override + public void mouseExited(MouseEvent e) { + lastMouse = null; + repaint(); + } + }; + addMouseListener(mouse); + addMouseMotionListener(mouse); + } + + Point getMousePositionInPanel() { + return lastMouse; + } + + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + ensureGraphs(); + applyGraphSizes(); + graphs().syncFrames(profileSampleStore); + graphs().paintStacked((Graphics2D) g, PANEL_PADDING, PANEL_PADDING); + } + } + + private class LegendPanel extends JPanel { + private final List hits = new ArrayList<>(); + + LegendPanel() { + setBackground(LEGEND_BACKGROUND); + setOpaque(true); + setPreferredSize(new Dimension(LEGEND_WIDTH, 200)); + + MouseAdapter mouse = new MouseAdapter() { + @Override + public void mouseMoved(MouseEvent e) { + Object prev = hoveredLegendKey; + hoveredLegendKey = findKeyAt(e.getPoint()); + setCursor(hoveredLegendKey != null ? Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) : Cursor.getDefaultCursor()); + if (prev != hoveredLegendKey) { + repaint(); + if (graphPanel != null) + graphPanel.repaint(); + } + } + + @Override + public void mouseExited(MouseEvent e) { + if (hoveredLegendKey != null) { + hoveredLegendKey = null; + setCursor(Cursor.getDefaultCursor()); + repaint(); + if (graphPanel != null) + graphPanel.repaint(); + } + } + }; + addMouseListener(mouse); + addMouseMotionListener(mouse); + } + + @Nullable + private Object findKeyAt(Point point) { + for (LegendHit hit : hits) { + if (hit.bounds.contains(point)) + return hit.key; + } + return null; + } + + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + hits.clear(); + + List categories = graphs().collectLegendCategories(); + Graphics2D g2d = (Graphics2D) g; + g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + + Font titleFont = new Font(Font.SANS_SERIF, Font.BOLD, 12); + Font categoryFont = new Font(Font.SANS_SERIF, Font.BOLD, 11); + Font rowFont = new Font(Font.SANS_SERIF, Font.PLAIN, 11); + g2d.setFont(titleFont); + FontMetrics titleFm = g2d.getFontMetrics(); + + int y = LEGEND_PADDING; + g2d.setColor(LEGEND_TEXT); + g2d.drawString("Key", LEGEND_PADDING, y + titleFm.getAscent()); + y += titleFm.getHeight() + 8; + + g2d.setFont(categoryFont); + FontMetrics categoryFm = g2d.getFontMetrics(); + g2d.setFont(rowFont); + FontMetrics fm = g2d.getFontMetrics(); + int rowPad = Math.max(0, (LEGEND_ROW_HEIGHT - fm.getHeight()) / 2); + + for (int c = 0; c < categories.size(); c++) { + ProfilerGraphs.LegendCategory category = categories.get(c); + if (c > 0) + y += 6; + + g2d.setFont(categoryFont); + g2d.setColor(new Color(180, 180, 180)); + g2d.drawString(category.getTitle(), LEGEND_PADDING, y + categoryFm.getAscent()); + y += categoryFm.getHeight() + 4; + + g2d.setFont(rowFont); + for (GraphComponent.LegendEntry entry : category.getEntries()) { + Rectangle rowBounds = new Rectangle(0, y, getWidth(), LEGEND_ROW_HEIGHT); + hits.add(new LegendHit(rowBounds, entry.getKey())); + + boolean hovered = isKeyActive(entry.getKey()); + if (hovered) { + g2d.setColor(LEGEND_HOVER_BG); + g2d.fillRect(rowBounds.x, rowBounds.y, rowBounds.width, rowBounds.height); + } + + int swatchX = LEGEND_PADDING; + int swatchY = y + (LEGEND_ROW_HEIGHT - LEGEND_SWATCH) / 2; + g2d.setColor(entry.getColor()); + g2d.fillRect(swatchX, swatchY, LEGEND_SWATCH, LEGEND_SWATCH); + g2d.setColor(LEGEND_BORDER); + g2d.drawRect(swatchX, swatchY, LEGEND_SWATCH, LEGEND_SWATCH); + + g2d.setColor(LEGEND_TEXT); + String label = entry.getName(); + int textX = swatchX + LEGEND_SWATCH + 8; + int maxTextWidth = getWidth() - textX - LEGEND_PADDING; + if (fm.stringWidth(label) > maxTextWidth) + label = truncate(fm, label, maxTextWidth); + g2d.drawString(label, textX, y + rowPad + fm.getAscent()); + + y += LEGEND_ROW_HEIGHT; + } + } + + int preferredHeight = Math.max(y + LEGEND_PADDING, getParent() != null ? getParent().getHeight() : y); + Dimension preferred = new Dimension(LEGEND_WIDTH - 16, preferredHeight); + if (!preferred.equals(getPreferredSize())) + setPreferredSize(preferred); + } + + private String truncate(FontMetrics fm, String text, int maxWidth) { + if (maxWidth <= 0) + return ""; + String ellipsis = "..."; + if (fm.stringWidth(ellipsis) > maxWidth) + return ""; + StringBuilder sb = new StringBuilder(text); + while (sb.length() > 0 && fm.stringWidth(sb + ellipsis) > maxWidth) + sb.setLength(sb.length() - 1); + return sb + ellipsis; + } + + private final class LegendHit { + final Rectangle bounds; + final Object key; + + LegendHit(Rectangle bounds, Object key) { + this.bounds = bounds; + this.key = key; + } + } + } +} diff --git a/src/main/java/rs117/hd/overlays/ProfilerGraphOverlay.java b/src/main/java/rs117/hd/overlays/ProfilerGraphOverlay.java new file mode 100644 index 0000000000..362b7e4463 --- /dev/null +++ b/src/main/java/rs117/hd/overlays/ProfilerGraphOverlay.java @@ -0,0 +1,188 @@ +package rs117.hd.overlays; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import java.awt.Dimension; +import java.awt.Graphics2D; +import java.awt.Point; +import java.awt.event.MouseEvent; +import javax.annotation.Nullable; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.*; +import net.runelite.client.eventbus.EventBus; +import net.runelite.client.input.MouseListener; +import net.runelite.client.input.MouseManager; +import net.runelite.client.ui.overlay.OverlayLayer; +import net.runelite.client.ui.overlay.OverlayManager; +import net.runelite.client.ui.overlay.OverlayPosition; +import rs117.hd.HdPlugin; +import rs117.hd.overlays.components.GraphComponent; +import rs117.hd.profiling.ProfileSample; +import rs117.hd.profiling.ProfileSampleStore; +import rs117.hd.profiling.Profiler; + +@Slf4j +@Singleton +public class ProfilerGraphOverlay extends HDOverlayPanel implements MouseListener { + private static final int SCREEN_MARGIN = 24; + private static final int PANEL_BORDER = 4; + + @Inject + private OverlayManager overlayManager; + + @Inject + private Client client; + + @Inject + private ProfilerOverlay profilerOverlay; + + @Inject + private ProfileSampleStore profileSampleStore; + + @Inject + private ProfilerUI ui; + + @Inject + private EventBus eventBus; + + @Inject + private MouseManager mouseManager; + + @Inject + private Profiler profiler; + + private ProfilerGraphs graphs; + + @Getter + private boolean active; + + private GraphComponent dragGraph; + private Point dragStartPoint; + + @Inject + public ProfilerGraphOverlay(HdPlugin plugin) { + super(plugin); + setLayer(OverlayLayer.ABOVE_SCENE); + setPosition(OverlayPosition.TOP_RIGHT); + setPreferredLocation(new Point(50, 10)); + setMinimumSize(GraphComponent.MIN_GRAPH_WIDTH / 2); + } + + private ProfilerGraphs graphs() { + if (graphs == null) + graphs = new ProfilerGraphs(ui); + return graphs; + } + + void ensureGraphs() { + ProfilerGraphs g = graphs(); + if (!g.isEmpty()) + return; + + g.create( + () -> profilerOverlay.getHoveredTimer(), + () -> { + var p = client.getMouseCanvasPosition(); + return p == null ? null : new Point(p.getX(), p.getY()); + } + ); + } + + public boolean hasGpuMemoryGraph() { + return graphs().hasGpuMemoryGraph(); + } + + public void setActive(boolean activate) { + active = activate; + if (activate) { + overlayManager.add(this); + eventBus.register(this); + mouseManager.registerMouseListener(this); + } else { + overlayManager.remove(this); + eventBus.unregister(this); + mouseManager.unregisterMouseListener(this); + dragGraph = null; + dragStartPoint = null; + } + } + + @Override + public Dimension onRender(Graphics2D g) { + ensureGraphs(); + graphs().applyOverlaySizes( + Math.max(1, client.getCanvasWidth()), + Math.max(1, client.getCanvasHeight()), + getPreferredSize(), + PANEL_BORDER, + SCREEN_MARGIN + ); + graphs().syncFrames(profileSampleStore); + graphs().forEachVisible(panelComponent.getChildren()::add); + + Dimension dimension = super.onRender(g); + graphs().renderTooltips(g); + return dimension; + } + + @Override + public MouseEvent mousePressed(MouseEvent event) { + if (active && event.getButton() == MouseEvent.BUTTON1) { + GraphComponent hit = findGraphAt(event.getPoint()); + if (hit != null) { + dragGraph = hit; + dragStartPoint = event.getPoint(); + event.consume(); + } + } + return event; + } + + @Override + public MouseEvent mouseDragged(MouseEvent event) { + if (dragGraph != null) { + dragGraph.setSelectionFromPoints(dragStartPoint, event.getPoint()); + event.consume(); + } + return event; + } + + @Override + public MouseEvent mouseReleased(MouseEvent event) { + if (dragGraph != null) { + if (dragStartPoint.distance(event.getPoint()) <= 0.01) + dragGraph.clearSelection(); + else + dragGraph.setSelectionFromPoints(dragStartPoint, event.getPoint()); + dragGraph = null; + dragStartPoint = null; + event.consume(); + } + return event; + } + + @Override + public MouseEvent mouseClicked(MouseEvent event) { return event; } + + @Override + public MouseEvent mouseEntered(MouseEvent event) { return event; } + + @Override + public MouseEvent mouseExited(MouseEvent event) { return event; } + + @Override + public MouseEvent mouseMoved(MouseEvent event) { return event; } + + @Nullable + private GraphComponent findGraphAt(Point canvasPoint) { + var panelBounds = getBounds(); + if (panelBounds == null || panelBounds.width <= 0 || panelBounds.height <= 0) + return null; + + return graphs().findAtLocal( + canvasPoint.x - panelBounds.x, + canvasPoint.y - panelBounds.y + ); + } +} diff --git a/src/main/java/rs117/hd/overlays/ProfilerGraphs.java b/src/main/java/rs117/hd/overlays/ProfilerGraphs.java new file mode 100644 index 0000000000..0d0f007b1c --- /dev/null +++ b/src/main/java/rs117/hd/overlays/ProfilerGraphs.java @@ -0,0 +1,436 @@ +package rs117.hd.overlays; + +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Graphics2D; +import java.awt.Point; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Supplier; +import javax.annotation.Nullable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import rs117.hd.overlays.components.GraphComponent; +import rs117.hd.profiling.Event; +import rs117.hd.profiling.ProfileSample; +import rs117.hd.profiling.ProfileSampleStore; +import rs117.hd.profiling.Timer; + +import static rs117.hd.HdPlugin.GL_CAPS; + +public class ProfilerGraphs { + private static final Color HEAP_COLOR = new Color(80, 200, 255); + private static final Color GPU_COLOR = new Color(80, 255, 95); + private static final Color MEMORY_COLOR = new Color(255, 80, 179); + + private static final long BYTES_PER_KB = 1024L; + private static final long KB_PER_MB = 1024L; + + public static final double DEFAULT_WIDTH_RATIO = 0.55; + public static final double DEFAULT_HEIGHT_RATIO = 0.70; + + @RequiredArgsConstructor + @Getter + public static final class GraphEntry { + private final ProfilerUI.Graph id; + private final GraphComponent component; + } + + private final ProfilerUI ui; + private final List timerGraphs = new ArrayList<>(); + private final List memoryGraphs = new ArrayList<>(); + private final List frames = new ArrayList<>(); + + public ProfilerGraphs(ProfilerUI ui) { + this.ui = ui; + } + + public boolean isEmpty() { + return timerGraphs.isEmpty() && memoryGraphs.isEmpty(); + } + + public void create(Supplier hoveredKeySupplier, Supplier mousePositionSupplier) { + Supplier> framesSupplier = () -> this.frames; + + timerGraphs.clear(); + memoryGraphs.clear(); + + var cpuGpuGraph = setupFrameTimerGraph(new GraphComponent<>("CPU/GPU", framesSupplier, hoveredKeySupplier, mousePositionSupplier), ProfilerUI.Graph.CPU_GPU); + var cpuGraph = setupFrameTimerGraph(new GraphComponent<>("CPU", framesSupplier, hoveredKeySupplier, mousePositionSupplier), ProfilerUI.Graph.CPU); + var asyncGraph = setupFrameTimerGraph(new GraphComponent<>("ASYNC", framesSupplier, hoveredKeySupplier, mousePositionSupplier), ProfilerUI.Graph.ASYNC); + var gpuGraph = setupFrameTimerGraph(new GraphComponent<>("GPU", framesSupplier, hoveredKeySupplier, mousePositionSupplier), ProfilerUI.Graph.GPU); + var allocationGraph = setupMemoryGraph(new GraphComponent<>("Allocations", framesSupplier, hoveredKeySupplier, mousePositionSupplier), true, ProfilerUI.Graph.ALLOCATIONS); + var heapMemoryGraph = setupMemoryGraph(new GraphComponent<>("Heap Memory", framesSupplier, () -> null, mousePositionSupplier), false, ProfilerUI.Graph.HEAP); + var systemMemoryGraph = setupMemoryGraph(new GraphComponent<>("System Memory", framesSupplier, () -> null, mousePositionSupplier), false, ProfilerUI.Graph.SYSTEM_MEMORY); + + if (GL_CAPS.GL_NVX_gpu_memory_info) + systemMemoryGraph.addSeries("GPU Memory", GPU_COLOR, f -> f.gpuUsageKB / (double) KB_PER_MB, false); + systemMemoryGraph.addSeries("Avail System Memory", MEMORY_COLOR, f -> f.freeSystemMemoryKB / (double) KB_PER_MB, false); + + heapMemoryGraph.addSeries("Heap Memory", HEAP_COLOR, f -> (f.heapUsageKB) / (double) KB_PER_MB, false); + + addMemorySeries(allocationGraph, Timer.CLIENT); + addTimerSeries(cpuGpuGraph, Timer.CLIENT); + addTimerSeries(cpuGpuGraph, Timer.DRAW_FRAME); + addTimerSeries(cpuGpuGraph, Timer.RENDER_FRAME); + + for (Timer t : Timer.TIMERS) { + if (t.isCpuTimer() && !isInGraph(cpuGpuGraph, t)) { + addTimerSeries(cpuGraph, t); + addMemorySeries(allocationGraph, t); + } + + if (t.isAsyncCpuTimer()) { + addTimerSeries(asyncGraph, t); + addMemorySeries(allocationGraph, t); + } + + if (t.isGpuTimer()) + addTimerSeries(gpuGraph, t); + } + } + + public void syncFrames(ProfileSampleStore store) { + if (hasSelection()) + return; + frames.clear(); + frames.addAll(store.getFrames()); + } + + public boolean hasSelection() { + for (GraphEntry entry : timerGraphs) { + if (entry.component.isSelectionActive()) + return true; + } + for (GraphEntry entry : memoryGraphs) { + if (entry.component.isSelectionActive()) + return true; + } + return false; + } + + @Nullable + public GraphComponent findAtLocal(int localX, int localY) { + Point point = new Point(localX, localY); + for (GraphEntry entry : timerGraphs) { + if (ui.isGraphVisible(entry.id) && entry.component.getBounds().contains(point)) + return entry.component; + } + for (GraphEntry entry : memoryGraphs) { + if (ui.isGraphVisible(entry.id) && entry.component.getBounds().contains(point)) + return entry.component; + } + return null; + } + + public void forEachVisible(Consumer> consumer) { + for (GraphEntry entry : timerGraphs) { + if (ui.isGraphVisible(entry.id)) + consumer.accept(entry.component); + } + for (GraphEntry entry : memoryGraphs) { + if (ui.isGraphVisible(entry.id)) + consumer.accept(entry.component); + } + } + + public void renderTooltips(Graphics2D g) { + for (GraphEntry entry : timerGraphs) { + if (ui.isGraphVisible(entry.id)) + entry.component.renderTooltip(g); + } + for (GraphEntry entry : memoryGraphs) { + if (ui.isGraphVisible(entry.id)) + entry.component.renderTooltip(g); + } + } + + /** Paint graphs stacked at (x, y) for Swing panels. Returns the content size used. */ + public Dimension paintStacked(Graphics2D g, int x, int y) { + int startY = y; + int maxWidth = 0; + + for (GraphEntry entry : timerGraphs) { + if (!ui.isGraphVisible(entry.id)) + continue; + entry.component.setPreferredLocation(new Point(x, y)); + Dimension size = entry.component.render(g); + maxWidth = Math.max(maxWidth, size.width); + y += size.height; + } + for (GraphEntry entry : memoryGraphs) { + if (!ui.isGraphVisible(entry.id)) + continue; + entry.component.setPreferredLocation(new Point(x, y)); + Dimension size = entry.component.render(g); + maxWidth = Math.max(maxWidth, size.width); + y += size.height; + } + + renderTooltips(g); + return new Dimension(maxWidth, y - startY); + } + + @RequiredArgsConstructor + @Getter + public static final class LegendCategory { + private final String title; + private final List entries; + } + + public List collectLegendEntries() { + Map unique = new LinkedHashMap<>(); + appendLegendEntries(unique, timerGraphs); + appendLegendEntries(unique, memoryGraphs); + return new ArrayList<>(unique.values()); + } + + public List collectLegendCategories() { + List cpu = new ArrayList<>(); + List gpu = new ArrayList<>(); + List memory = new ArrayList<>(); + + for (GraphComponent.LegendEntry entry : collectLegendEntries()) { + Object key = entry.getKey(); + if (key instanceof Timer) { + Timer timer = (Timer) key; + if (timer.isGpuTimer()) + gpu.add(entry); + else + cpu.add(entry); + } else { + memory.add(entry); + } + } + + List categories = new ArrayList<>(3); + if (!cpu.isEmpty()) + categories.add(new LegendCategory("CPU", cpu)); + if (!gpu.isEmpty()) + categories.add(new LegendCategory("GPU", gpu)); + if (!memory.isEmpty()) + categories.add(new LegendCategory("Memory", memory)); + return categories; + } + + /** + * Size plots to fill an available panel area (JFrame). + * Returns preferred content size for the panel. + */ + public Dimension applyPanelSizes(int panelW, int panelH, int padding, int fallbackWidth, int fallbackTimerH, int fallbackMemoryH) { + VisibleCounts counts = countVisible(); + if (counts.total == 0) + return new Dimension(fallbackWidth, 120); + + int plotWidth; + int timerHeight; + int memoryHeight; + + if (panelW > padding * 2 && panelH > padding * 2) { + int chromeHeight = counts.total * (GraphComponent.MARGIN_TOP + GraphComponent.MARGIN_BOTTOM); + plotWidth = clamp( + panelW - padding * 2 - GraphComponent.MARGIN_LEFT - GraphComponent.MARGIN_RIGHT, + GraphComponent.MIN_GRAPH_WIDTH, + GraphComponent.MAX_GRAPH_WIDTH + ); + int totalPlotHeight = Math.max(GraphComponent.MIN_GRAPH_HEIGHT, panelH - padding * 2 - chromeHeight); + int weight = weight(counts); + timerHeight = counts.timers > 0 + ? clamp((totalPlotHeight * 3) / weight, GraphComponent.MIN_GRAPH_HEIGHT, GraphComponent.MAX_GRAPH_HEIGHT) + : GraphComponent.DEFAULT_GRAPH_HEIGHT; + memoryHeight = counts.memory > 0 + ? clamp(totalPlotHeight / weight, GraphComponent.MIN_MEMORY_GRAPH_HEIGHT, GraphComponent.MAX_MEMORY_GRAPH_HEIGHT) + : GraphComponent.DEFAULT_MEMORY_GRAPH_HEIGHT; + } else { + plotWidth = fallbackWidth; + timerHeight = fallbackTimerH; + memoryHeight = fallbackMemoryH; + } + + setSizes(plotWidth, timerHeight, memoryHeight); + + int contentWidth = plotWidth + GraphComponent.MARGIN_LEFT + GraphComponent.MARGIN_RIGHT + padding * 2 + 40; + int contentHeight = padding * 2 + + counts.timers * GraphComponent.outerHeight(timerHeight) + + counts.memory * GraphComponent.outerHeight(memoryHeight); + return new Dimension(contentWidth, Math.max(contentHeight, 120)); + } + + /** Size plots for the in-game overlay using canvas constraints / preferred size. */ + public void applyOverlaySizes( + int canvasW, + int canvasH, + @Nullable Dimension preferred, + int panelBorder, + int screenMargin + ) { + VisibleCounts counts = countVisible(); + if (counts.total == 0) + return; + + int maxPlotWidth = Math.max( + GraphComponent.MIN_GRAPH_WIDTH, + canvasW - screenMargin - GraphComponent.MARGIN_LEFT - GraphComponent.MARGIN_RIGHT - panelBorder * 2 + ); + int maxContentHeight = Math.max( + GraphComponent.MIN_GRAPH_HEIGHT, + canvasH - screenMargin - panelBorder * 2 + ); + int chromeHeight = counts.total * (GraphComponent.MARGIN_TOP + GraphComponent.MARGIN_BOTTOM); + int maxTotalPlotHeight = Math.max(GraphComponent.MIN_GRAPH_HEIGHT, maxContentHeight - chromeHeight); + + int plotWidth; + int timerHeight; + int memoryHeight; + + if (preferred != null && preferred.width > 0 && preferred.height > 0) { + plotWidth = preferred.width - panelBorder * 2 - GraphComponent.MARGIN_LEFT - GraphComponent.MARGIN_RIGHT; + int totalPlotHeight = preferred.height - panelBorder * 2 - chromeHeight; + + plotWidth = clamp(plotWidth, GraphComponent.MIN_GRAPH_WIDTH, Math.min(GraphComponent.MAX_GRAPH_WIDTH, maxPlotWidth)); + totalPlotHeight = clamp(totalPlotHeight, GraphComponent.MIN_GRAPH_HEIGHT, maxTotalPlotHeight); + + int weight = weight(counts); + timerHeight = counts.timers > 0 + ? clamp((totalPlotHeight * 3) / weight, GraphComponent.MIN_GRAPH_HEIGHT, GraphComponent.MAX_GRAPH_HEIGHT) + : GraphComponent.DEFAULT_GRAPH_HEIGHT; + memoryHeight = counts.memory > 0 + ? clamp(totalPlotHeight / weight, GraphComponent.MIN_MEMORY_GRAPH_HEIGHT, GraphComponent.MAX_MEMORY_GRAPH_HEIGHT) + : GraphComponent.DEFAULT_MEMORY_GRAPH_HEIGHT; + } else { + plotWidth = clamp( + (int) (canvasW * DEFAULT_WIDTH_RATIO), + GraphComponent.MIN_GRAPH_WIDTH, + Math.min(GraphComponent.MAX_GRAPH_WIDTH, maxPlotWidth) + ); + + int preferredTimerHeight = defaultTimerHeight(canvasH, counts); + int preferredMemoryHeight = defaultMemoryHeight(canvasH, counts); + int preferredTotal = counts.timers * preferredTimerHeight + counts.memory * preferredMemoryHeight; + + if (preferredTotal > maxTotalPlotHeight && preferredTotal > 0) { + double scale = (double) maxTotalPlotHeight / preferredTotal; + timerHeight = clamp( + (int) Math.round(preferredTimerHeight * scale), + GraphComponent.MIN_GRAPH_HEIGHT, + GraphComponent.MAX_GRAPH_HEIGHT + ); + memoryHeight = clamp( + (int) Math.round(preferredMemoryHeight * scale), + GraphComponent.MIN_MEMORY_GRAPH_HEIGHT, + GraphComponent.MAX_MEMORY_GRAPH_HEIGHT + ); + } else { + timerHeight = preferredTimerHeight; + memoryHeight = preferredMemoryHeight; + } + } + + setSizes(plotWidth, timerHeight, memoryHeight); + } + + public boolean hasGpuMemoryGraph() { + return GL_CAPS.GL_NVX_gpu_memory_info; + } + + private void setSizes(int plotWidth, int timerHeight, int memoryHeight) { + for (GraphEntry entry : timerGraphs) + entry.component.setGraphSize(plotWidth, timerHeight); + for (GraphEntry entry : memoryGraphs) + entry.component.setGraphSize(plotWidth, memoryHeight); + } + + private void addEventsToGraph(GraphComponent graph) { + for(Event event : Event.EVENTS) + graph.addEventMarker(event.name, event.color, (f) -> f.events != null && Arrays.binarySearch(f.events, event) >= 0); + } + + private GraphComponent setupFrameTimerGraph(GraphComponent graph, ProfilerUI.Graph graphId) { + graph + .setYAxisName("ms") + .setAxisFormat("%.3f") + .setAppendAxisNameToTooltip(true); + addEventsToGraph(graph); + timerGraphs.add(new GraphEntry(graphId, graph)); + return graph; + } + + private GraphComponent setupMemoryGraph(GraphComponent graph, boolean isKB, ProfilerUI.Graph graphId) { + graph + .setRoundStep(50.0) + .setYAxisName(isKB ? "KB" : "MB") + .setAppendAxisNameToTooltip(true); + addEventsToGraph(graph); + memoryGraphs.add(new GraphEntry(graphId, graph)); + return graph; + } + + private void addTimerSeries(GraphComponent graph, Timer t) { + graph.addSeries(t.name, t.color, f -> f.timers[t.ordinal()] / 1e6, false, t); + } + + private void addMemorySeries(GraphComponent graph, Timer t) { + graph.addSeries(t.name, t.color, f -> f.allocations[t.ordinal()] / (double) BYTES_PER_KB, false, t); + } + + private boolean isInGraph(GraphComponent graph, Timer t) { + return graph.getSeries(t) != null; + } + + private void appendLegendEntries(Map unique, List graphs) { + for (GraphEntry entry : graphs) { + if (!ui.isGraphVisible(entry.id)) + continue; + for (GraphComponent.LegendEntry legend : entry.component.getLegendEntries()) + unique.putIfAbsent(legend.getKey(), legend); + } + } + + private VisibleCounts countVisible() { + int timers = 0; + int memory = 0; + for (GraphEntry entry : timerGraphs) { + if (ui.isGraphVisible(entry.id)) + timers++; + } + for (GraphEntry entry : memoryGraphs) { + if (ui.isGraphVisible(entry.id)) + memory++; + } + return new VisibleCounts(timers, memory, timers + memory); + } + + private static int weight(VisibleCounts counts) { + int weight = counts.timers * 3 + counts.memory; + return weight <= 0 ? 1 : weight; + } + + private static int defaultTimerHeight(int canvasH, VisibleCounts counts) { + int usable = (int) (canvasH * DEFAULT_HEIGHT_RATIO); + int chrome = counts.total * (GraphComponent.MARGIN_TOP + GraphComponent.MARGIN_BOTTOM); + int plotBudget = Math.max(GraphComponent.MIN_GRAPH_HEIGHT, usable - chrome); + return Math.max(GraphComponent.MIN_GRAPH_HEIGHT, (plotBudget * 3) / weight(counts)); + } + + private static int defaultMemoryHeight(int canvasH, VisibleCounts counts) { + int usable = (int) (canvasH * DEFAULT_HEIGHT_RATIO); + int chrome = counts.total * (GraphComponent.MARGIN_TOP + GraphComponent.MARGIN_BOTTOM); + int plotBudget = Math.max(GraphComponent.MIN_MEMORY_GRAPH_HEIGHT, usable - chrome); + return Math.max(GraphComponent.MIN_MEMORY_GRAPH_HEIGHT, plotBudget / weight(counts)); + } + + private static int clamp(int value, int min, int max) { + return Math.max(min, Math.min(max, value)); + } + + @RequiredArgsConstructor + private static final class VisibleCounts { + final int timers; + final int memory; + final int total; + } +} diff --git a/src/main/java/rs117/hd/overlays/ProfilerOverlay.java b/src/main/java/rs117/hd/overlays/ProfilerOverlay.java new file mode 100644 index 0000000000..e6dedceb3c --- /dev/null +++ b/src/main/java/rs117/hd/overlays/ProfilerOverlay.java @@ -0,0 +1,1568 @@ +package rs117.hd.overlays; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Graphics2D; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumMap; +import java.util.Formatter; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import javax.annotation.Nullable; +import javax.swing.SwingUtilities; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import net.runelite.api.*; +import net.runelite.client.input.MouseListener; +import net.runelite.client.input.MouseManager; +import net.runelite.client.ui.FontManager; +import net.runelite.client.ui.overlay.OverlayLayer; +import net.runelite.client.ui.overlay.OverlayManager; +import net.runelite.client.ui.overlay.OverlayPosition; +import net.runelite.client.ui.overlay.components.ComponentOrientation; +import net.runelite.client.ui.overlay.components.LayoutableRenderableEntity; +import net.runelite.client.ui.overlay.components.LineComponent; +import net.runelite.client.ui.overlay.components.PanelComponent; +import net.runelite.client.ui.overlay.components.SplitComponent; +import net.runelite.client.ui.overlay.components.TitleComponent; +import rs117.hd.HdPlugin; +import rs117.hd.overlays.components.SwatchComponent; +import rs117.hd.profiling.ProfileSample; +import rs117.hd.profiling.ProfileSampleStore; +import rs117.hd.profiling.Profiler; +import rs117.hd.profiling.Timer; +import rs117.hd.renderer.zone.SceneManager; +import rs117.hd.renderer.zone.WorldViewContext; +import rs117.hd.renderer.zone.ZoneRenderer; +import rs117.hd.utils.FrameTimingsRecorder; +import rs117.hd.utils.NpcDisplacementCache; +import rs117.hd.utils.collections.PooledArrayType; +import rs117.hd.utils.jobs.JobSystem; + +import static rs117.hd.renderer.zone.SceneManager.MAX_WORLDVIEWS; +import static rs117.hd.utils.MathUtils.*; + +@Singleton +public class ProfilerOverlay extends HDOverlayPanel implements Profiler.Listener, MouseListener { + private static final int PANEL_HORIZONTAL_PADDING = 8; + private static final int SWATCH_WIDTH = 10; + private static final int SWATCH_GAP = 4; + + @Inject + private OverlayManager overlayManager; + + @Inject + private MouseManager mouseManager; + + @Inject + private Client client; + + @Inject + private Profiler profiler; + + @Inject + private ProfileSampleStore profileSampleStore; + + @Inject + private ProfilerUI ui; + + @Inject + private FrameTimingsRecorder frameTimingsRecorder; + + @Inject + private NpcDisplacementCache npcDisplacementCache; + + @Inject + private JobSystem jobSystem; + + @Inject + private SceneManager sceneManager; + + @Inject + private ProfilerGraphOverlay profilerGraphOverlay; + + private final HdPlugin plugin; + private final long[] timings = new long[Timer.TIMERS.length]; + private float cpuLoad; + private final LineCache lineCache = new LineCache(); + private Header header; + private Header settingsHeader; + private DetachedSettingsPanel detachedSettingsPanel; + private final Map detachedPanels = new EnumMap<>(ProfilerUI.Tab.class); + + private final StringBuilder sb = new StringBuilder(); + private final Formatter formatter = new Formatter(sb); + + @Getter + @Nullable + private Timer hoveredTimer; + + private boolean overlayActive; + private int mainPanelWidth; + private int mainContentLineWidth; + private int mainPanelHeight; + + @Inject + public ProfilerOverlay(HdPlugin plugin) { + super(plugin); + this.plugin = plugin; + setLayer(OverlayLayer.ABOVE_SCENE); + setPosition(OverlayPosition.TOP_RIGHT); + panelComponent.setPreferredSize(new Dimension(280, 200)); + hoveredTimer = null; + } + + private Header getHeader() { + if (header == null) { + header = new Header( + ui, + frameTimingsRecorder, + profileSampleStore, + false, + ui::selectTab, + ui::toggleSettings, + ui::detachSettings, + () -> ui.setSettingsDetached(false), + tab -> ui.setDetached(tab, true), + ui::toggleGraph, + ui::toggleGraphDetached, + ui::toggleHidden, + tab -> ui.setDetached(tab, false) + ); + } + return header; + } + + private Header getSettingsHeader() { + if (settingsHeader == null) { + settingsHeader = new Header( + ui, + frameTimingsRecorder, + profileSampleStore, + true, + ui::selectTab, + ui::toggleSettings, + ui::detachSettings, + () -> ui.setSettingsDetached(false), + tab -> ui.setDetached(tab, true), + ui::toggleGraph, + ui::toggleGraphDetached, + ui::toggleHidden, + tab -> ui.setDetached(tab, false) + ); + } + return settingsHeader; + } + + public void setActive(boolean activate) { + if (overlayActive == activate) + return; + + overlayActive = activate; + if (activate) { + profiler.addTimingsListener(profileSampleStore); + overlayManager.add(this); + mouseManager.registerMouseListener(0, this); + ui.setChangeListener(u -> syncDetachedPanels()); + syncDetachedPanels(); + } else { + profiler.removeTimingsListener(profileSampleStore); + overlayManager.remove(this); + mouseManager.unregisterMouseListener(this); + profileSampleStore.clear(); + deactivateDetachedPanels(); + } + } + + private void syncDetachedPanels() { + int detachedIndex = 0; + for (ProfilerUI.Tab tab : ProfilerUI.Tab.values()) { + if (!tab.isDetachable()) + continue; + + if (ui.isDetached(tab)) { + DetachedPanel panel = detachedPanels.computeIfAbsent(tab, DetachedPanel::new); + if (!panel.isActive()) { + panel.setLocationOffset(detachedIndex); + panel.setActive(true); + } + detachedIndex++; + } else { + DetachedPanel panel = detachedPanels.get(tab); + if (panel != null) + panel.setActive(false); + } + } + + if (ui.isSettingsDetached()) { + if (detachedSettingsPanel == null) + detachedSettingsPanel = new DetachedSettingsPanel(); + if (!detachedSettingsPanel.isActive()) { + detachedSettingsPanel.setLocationOffset(detachedIndex); + detachedSettingsPanel.setActive(true); + } + } else if (detachedSettingsPanel != null) { + detachedSettingsPanel.setActive(false); + } + } + + private void deactivateDetachedPanels() { + for (DetachedPanel panel : detachedPanels.values()) + panel.setActive(false); + if (detachedSettingsPanel != null) + detachedSettingsPanel.setActive(false); + } + + @Override + public void onFrameCompletion(ProfileSample timings) { + profileSampleStore.onFrameCompletion(timings); + } + + @Override + public Dimension onRender(Graphics2D g) { + long time = System.nanoTime(); + + lineCache.syncGraphOverlayState(ui.isGraphEnabled()); + + int contentLineWidth = getHeader().computeContentLineWidth(g); + int panelWidth = getHeader().computePanelWidth(g) + PANEL_HORIZONTAL_PADDING; + ui.setLineWidth(contentLineWidth); + panelComponent.setPreferredSize(new Dimension(panelWidth, panelComponent.getPreferredSize().height)); + getHeader().setPreferredSize(new Dimension(panelWidth - PANEL_HORIZONTAL_PADDING, 0)); + + var children = panelComponent.getChildren(); + children.add(getHeader()); + + if (!getAverageTimings()) { + children.add(TitleComponent.builder() + .text("Waiting for data...") + .build()); + } else if (!ui.isInlineSettingsOpen()) { + renderTab(ui.getSelectedTab(), panelComponent, lineCache, timings, cpuLoad, contentLineWidth); + } + + var result = super.onRender(g); + mainPanelWidth = panelWidth; + mainContentLineWidth = contentLineWidth; + if (result.height > 0) + mainPanelHeight = result.height; + updateHoveredLine(); + profiler.cumulativeError += System.nanoTime() - time; + return result; + } + + private void updateHoveredLine() { + hoveredTimer = null; + + var mouse = client.getMouseCanvasPosition(); + if (mouse.getX() < 0 || mouse.getY() < 0) + return; + + Rectangle panelBounds = getBounds(); + if (panelBounds.width <= 0 || panelBounds.height <= 0) + return; + + hoveredTimer = lineCache.getHoveredTimer( + panelComponent, + panelBounds.x, + panelBounds.y, + mouse.getX(), + mouse.getY() + ); + } + + private boolean getAverageTimings() { + var frames = profileSampleStore.getFrames(); + if (frames.isEmpty()) + return false; + + Arrays.fill(timings, 0); + cpuLoad = 0; + for (var frame : frames) { + for (int i = 0; i < frame.timers.length; i++) + timings[i] += frame.timers[i]; + cpuLoad += frame.cpuLoad; + } + + for (int i = 0; i < timings.length; i++) + timings[i] = Math.max(0, timings[i] / frames.size()); + cpuLoad /= frames.size(); + + return true; + } + + private void renderTab( + ProfilerUI.Tab tab, + PanelComponent panel, + LineCache cache, + long[] timings, + float cpuLoad, + int lineWidth + ) { + cache.syncLineWidth(lineWidth); + + switch (tab) { + case ALL: + buildCpu(panel, cache, lineWidth, timings); + buildAsync(panel, cache, lineWidth, timings); + buildGpu(panel, cache, lineWidth, timings); + buildStats(panel, cache, lineWidth); + break; + case CPU: + buildCpu(panel, cache, lineWidth, timings); + break; + case ASYNC: + buildAsync(panel, cache, lineWidth, timings); + break; + case GPU: + buildGpu(panel, cache, lineWidth, timings); + break; + case STATS: + buildStats(panel, cache, lineWidth); + break; + } + + panel.getChildren().add(LineComponent.builder() + .preferredSize(new Dimension(lineWidth, 6)) + .build()); + + buildSummary(panel, lineWidth, timings, cpuLoad); + buildSnapshot(panel, lineWidth); + } + + private void buildStats(PanelComponent panel, LineCache cache, int lineWidth) { + var boldFont = FontManager.getRunescapeBoldFont(); + addLine(panel, lineWidth, LineComponent.builder() + .leftFont(boldFont) + .left("Stats:")); + + buildSceneContent(panel, lineWidth); + buildStreamingContent(panel, cache, lineWidth); + } + + private void addLine(PanelComponent panel, int lineWidth, LineComponent.LineComponentBuilder builder) { + panel.getChildren().add(builder.preferredSize(new Dimension(lineWidth, 0)).build()); + } + + private void buildSummary(PanelComponent panel, int lineWidth, long[] timings, float cpuLoad) { + var boldFont = FontManager.getRunescapeBoldFont(); + long cpuTime = timings[Timer.DRAW_FRAME.ordinal()]; + long gpuTime = timings[Timer.RENDER_FRAME.ordinal()]; + + addLine(panel, lineWidth, LineComponent.builder() + .leftFont(boldFont) + .left("Estimated bottleneck:") + .rightFont(boldFont) + .right(cpuTime > gpuTime ? "CPU" : "GPU")); + + addLine(panel, lineWidth, LineComponent.builder() + .leftFont(boldFont) + .left("Estimated FPS:") + .rightFont(boldFont) + .right(format("%.1f FPS", 1e9 / max(cpuTime, gpuTime)))); + + addLine(panel, lineWidth, LineComponent.builder() + .left("Error compensation:") + .right(format("%d ns", profiler.errorCompensation))); + + if (cpuLoad > 0) { + addLine(panel, lineWidth, LineComponent.builder() + .left("CPU Load:") + .right((int) (cpuLoad * 100) + "%")); + } + + addLine(panel, lineWidth, LineComponent.builder() + .left("Pooled array size:") + .right(formatBytes(PooledArrayType.getCurrentTotalCacheSize()))); + + addLine(panel, lineWidth, LineComponent.builder() + .left("Garbage collection count:") + .right(String.valueOf(plugin.getGarbageCollectionCount()))); + + addLine(panel, lineWidth, LineComponent.builder() + .left("Power saving mode:") + .right(plugin.isPowerSaving ? "ON" : "OFF")); + + if (!profileSampleStore.isCapturing()) { + var boldFont2 = FontManager.getRunescapeBoldFont(); + addLine(panel, lineWidth, LineComponent.builder() + .leftFont(boldFont2) + .left("Live capture:") + .rightFont(boldFont2) + .right("FROZEN")); + } + } + + private void buildCpu(PanelComponent panel, LineCache cache, int lineWidth, long[] timings) { + long cpuTime = timings[Timer.DRAW_FRAME.ordinal()]; + addTiming(panel, cache, lineWidth, "CPU", cpuTime, true, Timer.DRAW_FRAME.color, Timer.DRAW_FRAME); + for (var t : Timer.TIMERS) { + if (t.isCpuTimer() && t != Timer.DRAW_FRAME) + addTiming(panel, cache, lineWidth, t, timings); + } + } + + private void buildAsync(PanelComponent panel, LineCache cache, int lineWidth, long[] timings) { + long asyncCpuTime = 0; + for (var t : Timer.TIMERS) + if (t.isAsyncCpuTimer()) + asyncCpuTime += timings[t.ordinal()]; + + addTiming(panel, cache, lineWidth, "Async", asyncCpuTime, true, null, null); + for (var t : Timer.TIMERS) + if (t.isAsyncCpuTimer()) + addTiming(panel, cache, lineWidth, t, timings); + } + + private void buildGpu(PanelComponent panel, LineCache cache, int lineWidth, long[] timings) { + long gpuTime = timings[Timer.RENDER_FRAME.ordinal()]; + addTiming(panel, cache, lineWidth, "GPU", gpuTime, true, Timer.RENDER_FRAME.color, Timer.RENDER_FRAME); + for (var t : Timer.TIMERS) + if (t.isGpuTimer() && t != Timer.RENDER_FRAME) + addTiming(panel, cache, lineWidth, t, timings); + } + + private void buildSceneContent(PanelComponent panel, int lineWidth) { + if (plugin.getSceneContext() != null) { + var sceneContext = plugin.getSceneContext(); + addLine(panel, lineWidth, LineComponent.builder() + .left("Lights:") + .right(format("%d/%d", sceneContext.numVisibleLights, sceneContext.lights.size()))); + } + + if (plugin.renderer instanceof ZoneRenderer) { + addLine(panel, lineWidth, LineComponent.builder() + .left("Dynamic renderables:") + .right(String.valueOf(plugin.getDrawnDynamicRenderableCount()))); + + addLine(panel, lineWidth, LineComponent.builder() + .left("Temp renderables:") + .right(String.valueOf(plugin.getDrawnTempRenderableCount()))); + } else { + addLine(panel, lineWidth, LineComponent.builder() + .left("Tiles:") + .right(String.valueOf(plugin.getDrawnTileCount()))); + + addLine(panel, lineWidth, LineComponent.builder() + .left("Static renderables:") + .right(String.valueOf(plugin.getDrawnStaticRenderableCount()))); + + addLine(panel, lineWidth, LineComponent.builder() + .left("Dynamic renderables:") + .right(String.valueOf(plugin.getDrawnDynamicRenderableCount()))); + + addLine(panel, lineWidth, LineComponent.builder() + .left("NPC displacement cache size:") + .right(String.valueOf(npcDisplacementCache.size()))); + } + } + + private void buildStreamingContent(PanelComponent panel, LineCache cache, int lineWidth) { + WorldViewContext root = sceneManager.getRoot(); + addTiming(panel, cache, lineWidth, "Root Scene Load", root.loadTime, false, null, null); + addTiming(panel, cache, lineWidth, "Root Scene Upload", root.uploadTime, false, null, null); + addTiming(panel, cache, lineWidth, "Root Scene Swap", root.sceneSwapTime, false, null, null); + + int subSceneCount = 0; + long subSceneLoadTime = 0; + long subSceneUploadTime = 0; + long subSceneSwapTime = 0; + + for (int worldViewId = 0; worldViewId < MAX_WORLDVIEWS; worldViewId++) { + WorldViewContext subscene = sceneManager.getContext(worldViewId); + if (subscene != null) { + subSceneCount++; + subSceneLoadTime += subscene.loadTime; + subSceneUploadTime += subscene.uploadTime; + subSceneSwapTime += subscene.sceneSwapTime; + } + } + + if (subSceneCount > 0) { + addTiming(panel, cache, lineWidth, "Avg SubScene Load", subSceneLoadTime / subSceneCount, false, null, null); + addTiming(panel, cache, lineWidth, "Avg SubScene Upload", subSceneUploadTime / subSceneCount, false, null, null); + addTiming(panel, cache, lineWidth, "Avg SubScene Swap", subSceneSwapTime / subSceneCount, false, null, null); + } + + addLine(panel, lineWidth, LineComponent.builder() + .left("Sub Scene Count:") + .right(String.valueOf(subSceneCount))); + + addLine(panel, lineWidth, LineComponent.builder() + .left("Streaming Zones:") + .right(String.valueOf(jobSystem.getWorkQueueSize()))); + } + + private void buildSnapshot(PanelComponent panel, int lineWidth) { + if (!frameTimingsRecorder.isCapturingSnapshot()) + return; + + var boldFont = FontManager.getRunescapeBoldFont(); + addLine(panel, lineWidth, LineComponent.builder() + .leftFont(boldFont) + .left("Recording...") + .rightFont(boldFont) + .right(format( + "%ds left (%d%%)", + frameTimingsRecorder.getRemainingSeconds(), + frameTimingsRecorder.getProgressPercentage() + ))); + } + + private void addTiming(PanelComponent panel, LineCache cache, int lineWidth, Timer timer, long[] timings) { + addTiming(panel, cache, lineWidth, timer.name, timings[timer.ordinal()], false, timer.color, timer); + } + + private void addTiming( + PanelComponent panel, + LineCache cache, + int lineWidth, + String name, + long nanos, + boolean bold, + @Nullable Color swatchColor, + @Nullable Timer timer + ) { + if (nanos == 0) + return; + + String result = "~0 ms"; + if (abs(nanos) > 1e3) { + sb.setLength(0); + result = sb.append(round(nanos / 1e3) / 1e3).append(" ms").toString(); + } + + boolean showSwatch = swatchColor != null && profilerGraphOverlay.isActive(); + int textLineWidth = showSwatch ? lineWidth - SWATCH_WIDTH - SWATCH_GAP : lineWidth; + + TimerLineEntry entry = cache.lineEntries.get(name); + if (entry == null || entry.hasSwatch != showSwatch) { + var font = bold ? FontManager.getRunescapeBoldFont() : FontManager.getRunescapeFont(); + LineComponent lineComponent = LineComponent.builder() + .leftFont(font) + .right(result) + .rightFont(font) + .left(name + ":") + .preferredSize(new Dimension(textLineWidth, 0)) + .build(); + + LayoutableRenderableEntity component = lineComponent; + + if (showSwatch) { + component = SplitComponent.builder() + .orientation(ComponentOrientation.HORIZONTAL) + .first(new SwatchComponent(swatchColor)) + .second(lineComponent) + .gap(new Point(SWATCH_GAP, 0)) + .preferredSize(new Dimension(lineWidth, 0)) + .build(); + } + + entry = new TimerLineEntry(timer, component, lineComponent, showSwatch, textLineWidth); + cache.lineEntries.put(name, entry); + } else { + entry.lineComponent.setRight(result); + entry.lineComponent.setPreferredSize(new Dimension(textLineWidth, 0)); + } + + panel.getChildren().add(entry.component); + } + + private String format(String format, Object... args) { + sb.setLength(0); + formatter.format(format, args); + return sb.toString(); + } + + @Override + public MouseEvent mouseClicked(MouseEvent e) { + return e; + } + + @Override + public MouseEvent mousePressed(MouseEvent e) { + if (!overlayActive) + return e; + + Rectangle panelBounds = getBounds(); + if (panelBounds.width <= 0 || panelBounds.height <= 0) + return e; + + var mouse = client.getMouseCanvasPosition(); + if (mouse.getX() < 0 || mouse.getY() < 0) + return e; + + int localX = mouse.getX() - panelBounds.x; + int localY = mouse.getY() - panelBounds.y; + + Rectangle headerBounds = getHeader().getBounds(); + if (headerBounds.width <= 0 || headerBounds.height <= 0) + return e; + + int headerX = localX - headerBounds.x; + int headerY = localY - headerBounds.y; + if (headerX < 0 || headerY < 0 || headerX >= headerBounds.width || headerY >= headerBounds.height) + return e; + + boolean middleClick = SwingUtilities.isMiddleMouseButton(e); + + if (getHeader().handleClick(headerX, headerY, middleClick)) + e.consume(); + + return e; + } + + @Override + public MouseEvent mouseReleased(MouseEvent e) { + return e; + } + + @Override + public MouseEvent mouseEntered(MouseEvent e) { + return e; + } + + @Override + public MouseEvent mouseExited(MouseEvent e) { + return e; + } + + @Override + public MouseEvent mouseDragged(MouseEvent e) { + return e; + } + + @Override + public MouseEvent mouseMoved(MouseEvent e) { + return e; + } + + static final class LineCache { + private final IdentityHashMap lineEntries = new IdentityHashMap<>(); + private boolean graphOverlayActive; + private int lineWidth; + + void syncLineWidth(int lineWidth) { + if (lineWidth != this.lineWidth) { + this.lineWidth = lineWidth; + lineEntries.clear(); + } + } + + void syncGraphOverlayState(boolean graphActive) { + if (graphActive != graphOverlayActive) { + lineEntries.clear(); + graphOverlayActive = graphActive; + } + } + + @Nullable + Timer getHoveredTimer(PanelComponent panel, int panelX, int panelY, int mouseX, int mouseY) { + for (var entry : lineEntries.values()) { + if (entry.timer == null) + continue; + + Rectangle lineBounds = entry.component.getBounds(); + if (lineBounds.width <= 0 || lineBounds.height <= 0) + continue; + + lineBounds = new Rectangle( + panelX + lineBounds.x, + panelY + lineBounds.y, + lineBounds.width, + lineBounds.height + ); + + if (lineBounds.contains(mouseX, mouseY)) + return entry.timer; + } + return null; + } + } + + @RequiredArgsConstructor + static final class TimerLineEntry { + final Timer timer; + final LayoutableRenderableEntity component; + final LineComponent lineComponent; + final boolean hasSwatch; + final int textLineWidth; + } + + private static class Header implements LayoutableRenderableEntity { + private static final int BUTTON_PADDING_X = 6; + private static final int TAB_GAP = 2; + private static final int SETTINGS_GAP = 4; + static final int SETTINGS_MENU_MIN_WIDTH = 230; + private static final int MENU_PADDING = 6; + private static final int SECTION_GAP = 4; + + static final Color SETTINGS_BACKGROUND = new Color(25, 25, 25, 230); + private static final Color SETTINGS_BORDER = new Color(100, 100, 100, 200); + private static final Color MENU_TEXT = Color.WHITE; + private static final Color MENU_MUTED = new Color(160, 160, 160); + private static final Color MENU_ON = new Color(120, 220, 120); + private static final Color MENU_OFF = new Color(220, 120, 120); + private static final Color MENU_ACTION = new Color(140, 190, 255); + + private final ProfilerUI state; + private final FrameTimingsRecorder recorder; + private final ProfileSampleStore timingsStore; + private final boolean settingsOverlay; + private final Consumer onTabSelected; + private final Runnable onSettingsToggle; + private final Runnable onSettingsDetach; + private final Runnable onSettingsDock; + private final Consumer onTabDetached; + private final Runnable onGraphToggle; + private final Runnable onGraphWindowToggle; + private final Consumer onTabVisibilityToggle; + private final Consumer onTabAttach; + + private final Rectangle bounds = new Rectangle(); + private final List hitRegions = new ArrayList<>(); + + @Getter + private final List