diff --git a/src/main/java/rs117/hd/HdPlugin.java b/src/main/java/rs117/hd/HdPlugin.java index 5819dac606..bea709d54c 100644 --- a/src/main/java/rs117/hd/HdPlugin.java +++ b/src/main/java/rs117/hd/HdPlugin.java @@ -102,6 +102,7 @@ import rs117.hd.renderer.zone.SceneManager; import rs117.hd.renderer.zone.ZoneRenderer; import rs117.hd.scene.AreaManager; +import rs117.hd.scene.CustomSkyboxManager; import rs117.hd.scene.EnvironmentManager; import rs117.hd.scene.FishingSpotReplacer; import rs117.hd.scene.GamevalManager; @@ -217,6 +218,7 @@ public class HdPlugin extends Plugin { // Manually instantiate singletons and lazily inject them to avoid circular dependencies private static final List> LAZY_SINGLETONS = List.of( AreaManager.class, + CustomSkyboxManager.class, EnvironmentManager.class, GamevalManager.class, GroundMaterialManager.class, @@ -263,6 +265,9 @@ public class HdPlugin extends Plugin { @Inject private EnvironmentManager environmentManager; + @Inject + private CustomSkyboxManager customSkyboxManager; + @Inject private TextureManager textureManager; @@ -712,6 +717,7 @@ protected void startUp() { modelOverrideManager.startUp(); lightManager.startUp(); environmentManager.startUp(); + customSkyboxManager.startUp(); fishingSpotReplacer.startUp(); gammaCalibrationOverlay.initialize(); npcDisplacementCache.initialize(); @@ -781,6 +787,7 @@ protected void shutDown() { modelOverrideManager.shutDown(); lightManager.shutDown(); environmentManager.shutDown(); + customSkyboxManager.shutDown(); fishingSpotReplacer.shutDown(); areaManager.shutDown(); gamevalManager.shutDown(); diff --git a/src/main/java/rs117/hd/HdPluginConfig.java b/src/main/java/rs117/hd/HdPluginConfig.java index b24ab75201..e671920664 100644 --- a/src/main/java/rs117/hd/HdPluginConfig.java +++ b/src/main/java/rs117/hd/HdPluginConfig.java @@ -53,6 +53,7 @@ import rs117.hd.config.TextureResolution; import rs117.hd.config.UIScalingMode; import rs117.hd.config.VanillaShadowMode; +import rs117.hd.config.SkyboxTheme; import static rs117.hd.HdPlugin.MAX_DISTANCE; import static rs117.hd.HdPlugin.MAX_FOG_DEPTH; @@ -797,12 +798,54 @@ default boolean characterDisplacement() { default boolean pohThemeEnvironments() { return true; } + /*====== Skybox settings ======*/ + + @ConfigSection( + name = "Skybox", + description = "Settings for high-resolution 3D panoramic skybox rendering templates.", + position = 4, + closedByDefault = true + ) + String skyboxSettings = "skyboxSettings"; + + String KEY_SELECTED_SKYBOX_THEME = "selectedSkyboxTheme"; + @ConfigItem( + keyName = KEY_SELECTED_SKYBOX_THEME, + name = "Skybox Style", + description = "Select the panoramic 3D skybox style.
Select 'None' to use default 117 skybox.", + position = 0, + section = skyboxSettings + ) + default SkyboxTheme selectedSkyboxTheme() + { + return SkyboxTheme.NONE; + } + @ConfigItem(keyName = KEY_SELECTED_SKYBOX_THEME, hidden = true, name = "", description = "") + void selectedSkyboxTheme(SkyboxTheme theme); + + String KEY_CUSTOM_SKYBOX_NAME = "customSkyboxName"; + @ConfigItem( + keyName = KEY_CUSTOM_SKYBOX_NAME, + name = "Custom Skybox Name", + description = "When 'Skybox Style' is set to Custom, the skybox to use.
" + + "Drop an image into .runelite/117hd/custom-skyboxes/ and enter its filename here
" + + "(with or without its file extension), or add a manifest.json there for
" + + "cubemaps/multiple skies.
" + + "In-game commands: ::117hd skybox list, ::117hd skybox cycle, ::117hd skybox open.", + position = 1, + section = skyboxSettings + ) + default String customSkyboxName() { return ""; } + @ConfigItem(keyName = KEY_CUSTOM_SKYBOX_NAME, hidden = true, name = "", description = "") + void customSkyboxName(String name); + + /*====== Miscellaneous settings ======*/ @ConfigSection( name = "Miscellaneous", description = "Miscellaneous settings", - position = 4, + position = 5, closedByDefault = true ) String miscellaneousSettings = "miscellaneousSettings"; @@ -956,7 +999,7 @@ default boolean windowsHdrCorrection() { @ConfigSection( name = "Legacy", description = "Legacy options. If you dislike a change, you might find an option to change it back here.", - position = 5, + position = 6, closedByDefault = true ) String legacySettings = "legacySettings"; @@ -1109,7 +1152,7 @@ default boolean legacyTzHaarReskin() { @ConfigSection( name = "Experimental", description = "Experimental features - if you're experiencing issues you should consider disabling these.", - position = 6, + position = 7, closedByDefault = true ) String experimentalSettings = "experimentalSettings"; diff --git a/src/main/java/rs117/hd/config/SkyboxTheme.java b/src/main/java/rs117/hd/config/SkyboxTheme.java new file mode 100644 index 0000000000..6ce48db8e3 --- /dev/null +++ b/src/main/java/rs117/hd/config/SkyboxTheme.java @@ -0,0 +1,18 @@ +package rs117.hd.config; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SkyboxTheme { + NONE("None"), + CUSTOM("Custom"); + + private final String name; + + @Override + public String toString() { + return name; + } +} diff --git a/src/main/java/rs117/hd/opengl/shader/SkyboxShaderProgram.java b/src/main/java/rs117/hd/opengl/shader/SkyboxShaderProgram.java new file mode 100644 index 0000000000..01e2567ddc --- /dev/null +++ b/src/main/java/rs117/hd/opengl/shader/SkyboxShaderProgram.java @@ -0,0 +1,45 @@ +package rs117.hd.opengl.shader; + +import java.io.IOException; + +import static org.lwjgl.opengl.GL33C.*; + +public abstract class SkyboxShaderProgram extends ShaderProgram { + protected final UniformTexture uniSkyboxTexture = addUniformTexture("skyboxTexture"); + protected final UniformTexture uniSkyboxCubemap = addUniformTexture("skyboxCubemap"); + + protected boolean isCubemap; + + SkyboxShaderProgram() { + super(t -> t + .add(GL_VERTEX_SHADER, "skybox_vert.glsl") + .add(GL_FRAGMENT_SHADER, "skybox_frag.glsl")); + } + + @Override + public void compile(ShaderIncludes includes) throws ShaderException, IOException { + super.compile(includes.copy().define("IS_CUBEMAP", isCubemap)); + } + + public void setSkyboxTexture(int textureUnit) { + uniSkyboxTexture.set(textureUnit); + } + + public void setSkyboxCubemap(int textureUnit) { + uniSkyboxCubemap.set(textureUnit); + } + + public static class Equirect extends SkyboxShaderProgram { + public Equirect() { + isCubemap = false; + uniSkyboxCubemap.ignoreMissing = true; + } + } + + public static class Cubemap extends SkyboxShaderProgram { + public Cubemap() { + isCubemap = true; + uniSkyboxTexture.ignoreMissing = true; + } + } +} diff --git a/src/main/java/rs117/hd/renderer/zone/ZoneRenderer.java b/src/main/java/rs117/hd/renderer/zone/ZoneRenderer.java index be35d19b5c..6e5691ae14 100644 --- a/src/main/java/rs117/hd/renderer/zone/ZoneRenderer.java +++ b/src/main/java/rs117/hd/renderer/zone/ZoneRenderer.java @@ -43,19 +43,23 @@ import rs117.hd.config.ColorFilter; import rs117.hd.config.DynamicLights; import rs117.hd.config.ShadowMode; +import rs117.hd.config.SkyboxTheme; import rs117.hd.opengl.shader.SceneShaderProgram; import rs117.hd.opengl.shader.ShaderException; import rs117.hd.opengl.shader.ShaderIncludes; import rs117.hd.opengl.shader.ShadowShaderProgram; +import rs117.hd.opengl.shader.SkyboxShaderProgram; import rs117.hd.opengl.uniforms.UBOLights; import rs117.hd.opengl.uniforms.UBOWorldViews; import rs117.hd.overlays.FrameTimer; import rs117.hd.overlays.Timer; import rs117.hd.renderer.Renderer; +import rs117.hd.scene.CustomSkyboxManager; import rs117.hd.scene.EnvironmentManager; import rs117.hd.scene.LightManager; import rs117.hd.scene.ProceduralGenerator; import rs117.hd.scene.SceneContext; +import rs117.hd.scene.TextureManager; import rs117.hd.scene.lights.Light; import rs117.hd.utils.Camera; import rs117.hd.utils.ColorUtils; @@ -91,6 +95,24 @@ public class ZoneRenderer implements Renderer { private static int TEXTURE_UNIT_COUNT = HdPlugin.TEXTURE_UNIT_COUNT; public static final int TEXTURE_UNIT_TEXTURED_FACES = GL_TEXTURE0 + TEXTURE_UNIT_COUNT++; + public static final int TEXTURE_UNIT_SKYBOX = GL_TEXTURE0 + TEXTURE_UNIT_COUNT++; + public static final int TEXTURE_UNIT_SKYBOX_CUBEMAP = GL_TEXTURE0 + TEXTURE_UNIT_COUNT++; + private int customSkyboxTextureId; + private int customSkyboxCubemapTextureId; + private boolean loadedSkyboxIsCubemap; + private String loadedCustomSkyboxName; + + @Inject + private CustomSkyboxManager customSkyboxManager; + + @Inject + private TextureManager textureManager; + + @Inject + private SkyboxShaderProgram.Equirect skyboxEquirectProgram; + + @Inject + private SkyboxShaderProgram.Cubemap skyboxCubemapProgram; private static int UNIFORM_BLOCK_COUNT = HdPlugin.UNIFORM_BLOCK_COUNT; public static final int UNIFORM_BLOCK_WORLD_VIEWS = UNIFORM_BLOCK_COUNT++; @@ -233,6 +255,9 @@ public void initializeShaders(ShaderIncludes includes) throws ShaderException, I sceneProgram.compile(includes); fastShadowProgram.compile(includes); detailedShadowProgram.compile(includes); + skyboxEquirectProgram.compile(includes); + skyboxCubemapProgram.compile(includes); + // The actual skybox texture is loaded lazily in scenePass() once the configured theme is known } @Override @@ -240,6 +265,8 @@ public void destroyShaders() { sceneProgram.destroy(); fastShadowProgram.destroy(); detailedShadowProgram.destroy(); + skyboxEquirectProgram.destroy(); + skyboxCubemapProgram.destroy(); } private void initializeBuffers() { @@ -774,6 +801,62 @@ private void scenePass() { glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); frameTimer.end(Timer.CLEAR_SCENE); + // --- DYNAMIC SKYBOX THEME CONFIGURATION TRACKER --- + SkyboxTheme currentTheme = config.selectedSkyboxTheme(); + boolean hasActiveSkyboxTheme = currentTheme == SkyboxTheme.CUSTOM; + String currentCustomSkyboxName = config.customSkyboxName(); + + if (hasActiveSkyboxTheme && environmentManager.isOverworld()) { + // INTERCEPT: Check if the user changed the custom skybox name mid-game + if (!currentCustomSkyboxName.equals(loadedCustomSkyboxName)) { + // Free the old texture handles from GPU memory if they exist + if (customSkyboxTextureId != 0) { + glDeleteTextures(customSkyboxTextureId); + customSkyboxTextureId = 0; + } + if (customSkyboxCubemapTextureId != 0) { + glDeleteTextures(customSkyboxCubemapTextureId); + customSkyboxCubemapTextureId = 0; + } + loadedSkyboxIsCubemap = false; + + loadCustomSkybox(currentCustomSkyboxName); + loadedCustomSkyboxName = currentCustomSkyboxName; + } + + // Only run drawing commands if the selected asset loaded successfully + if (customSkyboxTextureId != 0 || customSkyboxCubemapTextureId != 0) { + glDisable(GL_DEPTH_TEST); + glDepthMask(false); + + // isCubemap is a compile-time #define, so equirect and cubemap skyboxes use + // separately-compiled shader variants rather than a per-pixel runtime branch. + // The camera ray itself is reconstructed in skybox_frag.glsl from the global + // UBO's viewMatrix/projectionMatrix, so no per-frame camera uniforms are needed. + if (loadedSkyboxIsCubemap) { + skyboxCubemapProgram.use(); + glActiveTexture(TEXTURE_UNIT_SKYBOX_CUBEMAP); + glBindTexture(GL_TEXTURE_CUBE_MAP, customSkyboxCubemapTextureId); + skyboxCubemapProgram.setSkyboxCubemap(TEXTURE_UNIT_SKYBOX_CUBEMAP); + } else { + skyboxEquirectProgram.use(); + glActiveTexture(TEXTURE_UNIT_SKYBOX); + glBindTexture(GL_TEXTURE_2D, customSkyboxTextureId); + skyboxEquirectProgram.setSkyboxTexture(TEXTURE_UNIT_SKYBOX); + } + + glDrawArrays(GL_TRIANGLES, 0, 3); + + glEnable(GL_DEPTH_TEST); + glDepthMask(true); + sceneProgram.use(); + } else { + sceneProgram.use(); + } + } else { + sceneProgram.use(); + } + frameTimer.begin(Timer.RENDER_SCENE); renderState.enable.set(GL_BLEND); @@ -1218,4 +1301,40 @@ public void swapScene(Scene scene) { plugin.stopPlugin(); } } -} + + /** + * Loads the custom skybox entry with the given name from {@link CustomSkyboxManager}, + * dispatching to the equirectangular or cubemap upload path depending on the entry's declared type. + * Populates {@link #customSkyboxTextureId}, {@link #customSkyboxCubemapTextureId} and + * {@link #loadedSkyboxIsCubemap}. Leaves both texture ids at 0 if the entry can't be found or loaded. + */ + private void loadCustomSkybox(String name) { + var entry = customSkyboxManager.getEntry(name); + if (entry == null) { + if (!name.isEmpty()) + log.warn("Custom skybox not found in manifest: {}", name); + return; + } + + try { + switch (entry.type) { + case "cubemap": + customSkyboxCubemapTextureId = textureManager.createCubemapTexture(customSkyboxManager.loadCubemapFaceImages(entry)); + loadedSkyboxIsCubemap = true; + break; + case "cubemap_cross": + customSkyboxCubemapTextureId = textureManager.createCubemapTexture(customSkyboxManager.loadCubemapCrossImage(entry)); + loadedSkyboxIsCubemap = true; + break; + case "equirect": + customSkyboxTextureId = textureManager.createTexture2D(customSkyboxManager.loadEquirectImage(entry)); + break; + default: + log.warn("Unknown custom skybox type '{}' for entry '{}'", entry.type, name); + } + } catch (Exception ex) { + log.warn("Failed to load custom skybox '{}'", name, ex); + } + } + +} \ No newline at end of file diff --git a/src/main/java/rs117/hd/scene/CustomSkyboxEntry.java b/src/main/java/rs117/hd/scene/CustomSkyboxEntry.java new file mode 100644 index 0000000000..4f89dbf407 --- /dev/null +++ b/src/main/java/rs117/hd/scene/CustomSkyboxEntry.java @@ -0,0 +1,8 @@ +package rs117.hd.scene; + +public class CustomSkyboxEntry { + public String name; + public String type; // "equirect" | "cubemap" | "cubemap_cross" + public String file; // equirect / cubemap_cross + public String[] faces; // cubemap, length 6: +X,-X,+Y,-Y,+Z,-Z +} diff --git a/src/main/java/rs117/hd/scene/CustomSkyboxManager.java b/src/main/java/rs117/hd/scene/CustomSkyboxManager.java new file mode 100644 index 0000000000..ddee9fb5df --- /dev/null +++ b/src/main/java/rs117/hd/scene/CustomSkyboxManager.java @@ -0,0 +1,169 @@ +package rs117.hd.scene; + +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import javax.inject.Inject; +import javax.inject.Singleton; +import lombok.extern.slf4j.Slf4j; +import rs117.hd.HdPlugin; +import rs117.hd.utils.FileWatcher; +import rs117.hd.utils.Props; +import rs117.hd.utils.ResourcePath; + +/** + * Loads user-supplied skybox textures from {@code /117hd/custom-skyboxes/}, + * allowing custom skyboxes to be added without rebuilding the plugin. Supports equirectangular + * images, 6-face cubemaps, and unwrapped horizontal-cross cubemap images. + *

+ * If a {@code manifest.json} is present in that folder, it takes full control over the available + * skyboxes (name, type, files). Otherwise, every supported image file found directly in the + * folder is exposed as an equirectangular skybox named after its filename, so that simply + * dropping a single panorama image in works with no configuration at all. + */ +@Slf4j +@Singleton +public class CustomSkyboxManager { + private static final String[] SUPPORTED_IMAGE_EXTENSIONS = { "png", "jpg", "jpeg" }; + private static final ResourcePath CUSTOM_SKYBOX_DIR = Props + .getFolder("rlhd.custom-skyboxes-path", () -> HdPlugin.PLUGIN_DIR.resolve("custom-skyboxes")); + + @Inject + private HdPlugin plugin; + + @Inject + private ScheduledExecutorService executor; + + @Inject + private TextureManager textureManager; + + private FileWatcher.UnregisterCallback fileWatcher; + private ScheduledFuture debounce; + private Map skyboxesByName = new HashMap<>(); + + public void startUp() { + // The directory must exist before a filesystem watcher can be registered on it + CUSTOM_SKYBOX_DIR.mkdirs(); + + fileWatcher = CUSTOM_SKYBOX_DIR.watch((path, first) -> { + if (first) { + reload(); + return; + } + + // Debounce in case several files change at once (e.g. copying in a cubemap's 6 faces) + if (debounce == null || debounce.cancel(false) || debounce.isDone()) + debounce = executor.schedule(this::reload, 200, TimeUnit.MILLISECONDS); + }); + } + + public void shutDown() { + if (fileWatcher != null) + fileWatcher.unregister(); + fileWatcher = null; + if (debounce != null) + debounce.cancel(false); + debounce = null; + skyboxesByName = new HashMap<>(); + } + + private void reload() { + CustomSkyboxEntry[] manifestEntries = null; + try { + manifestEntries = CUSTOM_SKYBOX_DIR.resolve("manifest.json").loadJson(plugin.getGson(), CustomSkyboxEntry[].class); + } catch (IOException ignored) { + // No manifest.json, or it's invalid - fall back to auto-detecting image files below + } + + var map = new HashMap(); + if (manifestEntries != null && manifestEntries.length > 0) { + for (var entry : manifestEntries) { + if (entry.name == null) + continue; + if (entry.type == null) + entry.type = "equirect"; + map.put(entry.name, entry); + } + } else { + for (String filename : listImageFilenames()) { + var entry = new CustomSkyboxEntry(); + entry.name = stripExtension(filename); + entry.type = "equirect"; + entry.file = filename; + map.put(entry.name, entry); + } + } + + skyboxesByName = map; + log.debug("Loaded {} custom skybox(es)", map.size()); + } + + private List listImageFilenames() { + File[] files = CUSTOM_SKYBOX_DIR.toFile().listFiles(); + if (files == null) + return List.of(); + + var names = new ArrayList(); + outer: + for (var file : files) { + if (!file.isFile()) + continue; + String lowerName = file.getName().toLowerCase(); + for (var ext : SUPPORTED_IMAGE_EXTENSIONS) { + if (lowerName.endsWith("." + ext)) { + names.add(file.getName()); + continue outer; + } + } + } + names.sort(String.CASE_INSENSITIVE_ORDER); + return names; + } + + private static String stripExtension(String filename) { + int i = filename.lastIndexOf('.'); + return i < 0 ? filename : filename.substring(0, i); + } + + public List getAvailableNames() { + return new ArrayList<>(skyboxesByName.keySet()); + } + + public File getDirectory() { + return CUSTOM_SKYBOX_DIR.toFile(); + } + + /** + * Looks up an entry by name, tolerating names given with or without a file extension (e.g. + * both "sunset" and "sunset.png" resolve the same entry), since File Explorer hides + * extensions by default on Windows and users commonly copy a name with or without one. + */ + public CustomSkyboxEntry getEntry(String name) { + var entry = skyboxesByName.get(name); + if (entry != null) + return entry; + return skyboxesByName.get(stripExtension(name)); + } + + public BufferedImage loadEquirectImage(CustomSkyboxEntry entry) { + return textureManager.loadImage(CUSTOM_SKYBOX_DIR, entry.file, SUPPORTED_IMAGE_EXTENSIONS); + } + + public BufferedImage[] loadCubemapFaceImages(CustomSkyboxEntry entry) { + var faces = new BufferedImage[6]; + for (int i = 0; i < 6; i++) + faces[i] = textureManager.loadImage(CUSTOM_SKYBOX_DIR, entry.faces[i], SUPPORTED_IMAGE_EXTENSIONS); + return faces; + } + + public BufferedImage[] loadCubemapCrossImage(CustomSkyboxEntry entry) { + return TextureManager.sliceHorizontalCross(textureManager.loadImage(CUSTOM_SKYBOX_DIR, entry.file, SUPPORTED_IMAGE_EXTENSIONS)); + } +} diff --git a/src/main/java/rs117/hd/scene/EnvironmentManager.java b/src/main/java/rs117/hd/scene/EnvironmentManager.java index b19eb18f64..cf6ff3e402 100644 --- a/src/main/java/rs117/hd/scene/EnvironmentManager.java +++ b/src/main/java/rs117/hd/scene/EnvironmentManager.java @@ -500,4 +500,6 @@ public boolean isUnderwater() { public boolean allowRoofShadows() { return currentEnvironment.allowRoofShadows; } + + public boolean isOverworld() { return currentEnvironment.isOverworld; } } diff --git a/src/main/java/rs117/hd/scene/TextureManager.java b/src/main/java/rs117/hd/scene/TextureManager.java index 48d94a459a..62b8089f79 100644 --- a/src/main/java/rs117/hd/scene/TextureManager.java +++ b/src/main/java/rs117/hd/scene/TextureManager.java @@ -28,6 +28,7 @@ import java.awt.image.AffineTransformOp; import java.awt.image.BufferedImage; import java.awt.image.DataBufferInt; +import java.nio.ByteBuffer; import java.nio.IntBuffer; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -176,18 +177,59 @@ public BufferedImage loadTexture(@Nullable String filename, int fallbackVanillaI @Nullable public BufferedImage loadTexture(String filename) { - for (String ext : SUPPORTED_IMAGE_EXTENSIONS) { - ResourcePath path = TEXTURE_PATH.resolve(filename + "." + ext); + return loadImage(TEXTURE_PATH, filename, SUPPORTED_IMAGE_EXTENSIONS); + } + + /** + * Resolves {@code filename} against {@code basePath} and loads it as an image, tolerating + * filenames given both with and without an extension: the filename is tried as-is first (in + * case it already ends in a valid extension), then with each of {@code extensions} appended. + */ + @Nullable + public BufferedImage loadImage(ResourcePath basePath, String filename, String[] extensions) { + try { + return basePath.resolve(filename).loadImage(); + } catch (Exception ignored) { + // Not a valid path as given - fall through to trying each supported extension below + } + + for (String ext : extensions) { + ResourcePath path = basePath.resolve(filename + "." + ext); try { return path.loadImage(); } catch (Exception ex) { - log.trace("Unable to load texture: {}", path, ex); + log.trace("Unable to load image: {}", path, ex); } } return null; } + /** + * Slices a standard unwrapped horizontal-cross cubemap layout (4 columns x 3 rows) into + * the 6 individual cube faces, in +X,-X,+Y,-Y,+Z,-Z order: + *

+	 *        [+Y]
+	 * [-X] [+Z] [+X] [-Z]
+	 *        [-Y]
+	 * 
+ */ + public static BufferedImage[] sliceHorizontalCross(BufferedImage cross) { + int faceSize = cross.getWidth() / 4; + if (faceSize <= 0 || cross.getHeight() / 3 != faceSize) + throw new IllegalArgumentException( + "Cubemap cross image must have a 4:3 aspect ratio (width / 4 == height / 3)"); + + return new BufferedImage[] { + cross.getSubimage(2 * faceSize, faceSize, faceSize, faceSize), // +X + cross.getSubimage(0, faceSize, faceSize, faceSize), // -X + cross.getSubimage(faceSize, 0, faceSize, faceSize), // +Y + cross.getSubimage(faceSize, 2 * faceSize, faceSize, faceSize), // -Y + cross.getSubimage(faceSize, faceSize, faceSize, faceSize), // +Z + cross.getSubimage(3 * faceSize, faceSize, faceSize, faceSize), // -Z + }; + } + public void uploadTexture(int target, int textureLayer, int[] textureSize, BufferedImage image) { assert client.isClientThread() : "Not thread safe"; @@ -241,4 +283,68 @@ public void setAnisotropicFilteringLevel() { glTexParameterf(GL_TEXTURE_2D_ARRAY, EXTTextureFilterAnisotropic.GL_TEXTURE_MAX_ANISOTROPY_EXT, clamp(level, 1, maxSamples)); } } + + /** + * Packs a BufferedImage's ARGB pixels into a tightly-packed RGBA ByteBuffer suitable for + * uploading via glTexImage2D. + */ + private static ByteBuffer packRgba(BufferedImage img) { + int width = img.getWidth(); + int height = img.getHeight(); + int[] pixels = new int[width * height]; + img.getRGB(0, 0, width, height, pixels, 0, width); + + ByteBuffer buffer = BufferUtils.createByteBuffer(width * height * 4); + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + int pixel = pixels[y * width + x]; + buffer.put((byte) ((pixel >> 16) & 0xFF)); + buffer.put((byte) ((pixel >> 8) & 0xFF)); + buffer.put((byte) (pixel & 0xFF)); + buffer.put((byte) ((pixel >> 24) & 0xFF)); + } + } + buffer.flip(); + return buffer; + } + + /** + * Uploads a single BufferedImage as a standalone GL_TEXTURE_2D and returns its texture id. + */ + public int createTexture2D(BufferedImage img) { + int width = img.getWidth(); + int height = img.getHeight(); + ByteBuffer buffer = packRgba(img); + + int texId = glGenTextures(); + glBindTexture(GL_TEXTURE_2D, texId); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, buffer); + return texId; + } + + /** + * Uploads 6 face images (in +X,-X,+Y,-Y,+Z,-Z order) as a GL_TEXTURE_CUBE_MAP and returns its texture id. + */ + public int createCubemapTexture(BufferedImage[] faces) { + int texId = glGenTextures(); + glBindTexture(GL_TEXTURE_CUBE_MAP, texId); + glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE); + + for (int i = 0; i < 6; i++) { + var face = faces[i]; + glTexImage2D( + GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_RGBA8, + face.getWidth(), face.getHeight(), 0, GL_RGBA, GL_UNSIGNED_BYTE, packRgba(face)); + } + return texId; + } } diff --git a/src/main/java/rs117/hd/utils/DeveloperTools.java b/src/main/java/rs117/hd/utils/DeveloperTools.java index 36d42b6171..393e0c852e 100644 --- a/src/main/java/rs117/hd/utils/DeveloperTools.java +++ b/src/main/java/rs117/hd/utils/DeveloperTools.java @@ -1,9 +1,12 @@ package rs117.hd.utils; import java.awt.event.KeyEvent; +import java.util.List; import javax.inject.Inject; import lombok.Getter; import lombok.extern.slf4j.Slf4j; +import net.runelite.api.Client; +import net.runelite.api.ChatMessageType; import net.runelite.api.events.*; import net.runelite.client.callback.ClientThread; import net.runelite.client.config.Keybind; @@ -12,12 +15,15 @@ import net.runelite.client.input.KeyListener; import net.runelite.client.input.KeyManager; import rs117.hd.HdPlugin; +import rs117.hd.HdPluginConfig; +import rs117.hd.config.SkyboxTheme; import rs117.hd.overlays.FrameTimerOverlay; import rs117.hd.overlays.LightGizmoOverlay; import rs117.hd.overlays.ShadowMapOverlay; import rs117.hd.overlays.TileInfoOverlay; import rs117.hd.overlays.TiledLightingOverlay; import rs117.hd.scene.AreaManager; +import rs117.hd.scene.CustomSkyboxManager; import rs117.hd.scene.areas.AABB; import rs117.hd.scene.areas.Area; @@ -38,6 +44,9 @@ public class DeveloperTools implements KeyListener { private static final Keybind KEY_TOGGLE_HIDE_UI = new Keybind(KeyEvent.VK_H, CTRL_DOWN_MASK); private static final Keybind KEY_RELOAD_SCENE = new Keybind(KeyEvent.VK_R, CTRL_DOWN_MASK); + @Inject + private Client client; + @Inject private ClientThread clientThread; @@ -50,6 +59,12 @@ public class DeveloperTools implements KeyListener { @Inject private HdPlugin plugin; + @Inject + private HdPluginConfig config; + + @Inject + private CustomSkyboxManager customSkyboxManager; + @Inject private TileInfoOverlay tileInfoOverlay; @@ -169,9 +184,55 @@ public void onCommandExecuted(CommandExecuted commandExecuted) { case "culling": plugin.freezeCulling = !plugin.freezeCulling; break; + case "skybox": + onSkyboxCommand(args); + break; + } + } + + private void onSkyboxCommand(String[] args) { + if (args.length < 2) { + sendChatMessage("Usage: ::117hd skybox "); + return; + } + + List names = customSkyboxManager.getAvailableNames(); + switch (args[1].toLowerCase()) { + case "list": + if (names.isEmpty()) { + sendChatMessage("No custom skyboxes found in .runelite/117hd/custom-skyboxes/"); + } else { + sendChatMessage("Custom skyboxes: " + String.join(", ", names)); + } + break; + case "cycle": + case "next": + if (names.isEmpty()) { + sendChatMessage("No custom skyboxes found in .runelite/117hd/custom-skyboxes/"); + break; + } + int index = names.indexOf(config.customSkyboxName()); + String next = names.get((index + 1) % names.size()); + config.customSkyboxName(next); + config.selectedSkyboxTheme(SkyboxTheme.CUSTOM); + sendChatMessage("Custom skybox set to: " + next); + break; + case "open": + try { + java.awt.Desktop.getDesktop().open(customSkyboxManager.getDirectory()); + } catch (Exception ex) { + log.warn("Failed to open custom skyboxes folder", ex); + sendChatMessage("Failed to open folder: " + customSkyboxManager.getDirectory()); + } + break; } } + private void sendChatMessage(String message) { + clientThread.invoke(() -> client.addChatMessage( + ChatMessageType.GAMEMESSAGE, "117 HD", "[117 HD] " + message + "", "117 HD")); + } + @Override public void keyPressed(KeyEvent e) { if (KEY_TOGGLE_TILE_INFO.matches(e)) { diff --git a/src/main/resources/rs117/hd/skybox_frag.glsl b/src/main/resources/rs117/hd/skybox_frag.glsl new file mode 100644 index 0000000000..0e76ea818a --- /dev/null +++ b/src/main/resources/rs117/hd/skybox_frag.glsl @@ -0,0 +1,34 @@ +#version 330 + +#include IS_CUBEMAP +#include + +in vec2 screenUV; +out vec4 FragColor; + +#if IS_CUBEMAP + uniform samplerCube skyboxCubemap; +#else + uniform sampler2D skyboxTexture; +#endif + +void main() { + // Reconstruct a world-space view ray for this pixel from the global UBO's matrices, + // the same way tiled_lighting_frag.glsl does, instead of tracking camera yaw/pitch separately. + vec2 ndc = screenUV * 2.0 - 1.0; + vec4 p = invProjectionMatrix * vec4(ndc, 1e-10, 1.0); + vec3 dir = normalize((p.xyz / p.w) - cameraPos); + +#if IS_CUBEMAP + FragColor = texture(skyboxCubemap, dir); +#else + // --- APPLY VERTICAL SHIFT HERE --- + // Slightly offsetting the Y lookup pushes the panorama horizon downwards + float shiftedY = dir.y - 0.12; + + // Map UV coordinates using our shifted position variable + vec2 uv = vec2(atan(dir.x, -dir.z) / (2.0 * 3.14159265) + 0.5, 1.0 - (acos(clamp(shiftedY, -1.0, 1.0)) / 3.14159265)); + + FragColor = texture(skyboxTexture, uv); +#endif +} diff --git a/src/main/resources/rs117/hd/skybox_vert.glsl b/src/main/resources/rs117/hd/skybox_vert.glsl new file mode 100644 index 0000000000..7f5715eabb --- /dev/null +++ b/src/main/resources/rs117/hd/skybox_vert.glsl @@ -0,0 +1,9 @@ +#version 330 + +out vec2 screenUV; + +void main() { + vec2 pos[3] = vec2[3](vec2(-1, -1), vec2(3, -1), vec2(-1, 3)); + gl_Position = vec4(pos[gl_VertexID], 1.0, 1.0); + screenUV = pos[gl_VertexID] * 0.5 + 0.5; +}