diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/CameraMedium.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/CameraMedium.java index 3860b6a..eea8377 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/CameraMedium.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/CameraMedium.java @@ -1,5 +1,6 @@ package eu.mhsl.minecraft.pixelpics.render.raytrace; +import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext; import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot; import eu.mhsl.minecraft.pixelpics.render.tint.BiomeClimate; import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider; @@ -18,10 +19,19 @@ import org.bukkit.util.Vector; * the biomes around the camera with vanilla's {@code GaussianSampler} (6x6x6 quart cells, kernel 1-4-6-4-1). * Water vision ({@code LocalPlayer.getWaterVision}) is taken as fully adapted (1), which keeps the end distance and * brightens the colour to a maximum channel of 1. Lava: #991A00 from 0.25 to 1 block (no fire resistance). + * + *

Air outside the overworld: the Nether fogs like vanilla's environmental fog (dimension attributes + * {@code fog_start_distance} 10, {@code fog_end_distance} 96) towards the sky context's fog colour (the camera biome's + * {@code fog_color}); the End uses the atmospheric fog towards its fog colour (#181318) instead of the sky. + * + * @param fogColor in air: the fixed colour distant surfaces fog towards, or -1 for the sky colour in their direction + * @param fogMax the strongest fog (1 = opaque) */ -public record CameraMedium(int fluid, int fogColor, double fogStart, double fogEnd) { +public record CameraMedium(int fluid, int fogColor, double fogStart, double fogEnd, double fogMax) { - public static final CameraMedium AIR = new CameraMedium(FluidRenderer.NONE, 0, 0, 0); + /** Overworld air: PixelPics' atmospheric fog towards the sky. */ + public static final CameraMedium AIR = + new CameraMedium(FluidRenderer.NONE, -1, CameraMedium.AIR_FOG_START, CameraMedium.AIR_FOG_END, 0.75); private static final double[] KERNEL = {0, 1, 4, 6, 4, 1, 0}; @@ -29,6 +39,9 @@ public record CameraMedium(int fluid, int fogColor, double fogStart, double fogE private static final double AIR_FOG_START = 128; private static final double AIR_FOG_END = 256; private static final double AIR_FOG_MAX = 0.75; + // Nether: dimension type attributes minecraft:visual/fog_start_distance / fog_end_distance (vanilla 26.3). + private static final double NETHER_FOG_START = 10; + private static final double NETHER_FOG_END = 96; // Vanilla sky disc (SkyRenderer.buildSkyDisc): a fan at height 16 around the camera, rim octagon at 512. private static final double DISC_HEIGHT = 16; @@ -40,10 +53,16 @@ public record CameraMedium(int fluid, int fogColor, double fogStart, double fogE return fluid != FluidRenderer.NONE; } - static CameraMedium at(WorldSnapshot snapshot, Vector eye, int fluid, BiomeTintProvider tints) { + static CameraMedium at(WorldSnapshot snapshot, Vector eye, int fluid, BiomeTintProvider tints, SkyContext sky) { double x = eye.getX(), y = eye.getY(), z = eye.getZ(); - if (fluid == FluidRenderer.LAVA) return new CameraMedium(fluid, 0x991A00, 0.25, 1.0); - if (fluid != FluidRenderer.WATER) return AIR; + if (fluid == FluidRenderer.LAVA) return new CameraMedium(fluid, 0x991A00, 0.25, 1.0, 1); + if (fluid != FluidRenderer.WATER) { + return switch (sky.skybox()) { + case OVERWORLD -> AIR; + case NONE -> new CameraMedium(fluid, sky.fogColor(), NETHER_FOG_START, NETHER_FOG_END, 1); + case END -> new CameraMedium(fluid, sky.fogColor(), AIR_FOG_START, AIR_FOG_END, AIR_FOG_MAX); + }; + } // GaussianSampler.sample(position / 4) over the noise biomes (quart positions). double px = x * 0.25 - 0.5, py = y * 0.25 - 0.5, pz = z * 0.25 - 0.5; @@ -85,7 +104,7 @@ public record CameraMedium(int fluid, int fogColor, double fogStart, double fogE b = b + (b * scale - b); } int color = ColorUtil.argb(255, channel(r), channel(g), channel(b)) & 0xFFFFFF; - return new CameraMedium(fluid, color, start, end); + return new CameraMedium(fluid, color, start, end, 1); } private static double lerp(double a, double from, double to) { @@ -98,24 +117,20 @@ public record CameraMedium(int fluid, int fogColor, double fogStart, double fogE /** Fog fraction at distance {@code d} (in fluids fog.glsl {@code linear_fog_value}). */ double fogValue(double d) { - if (!inFluid()) { - if (d <= AIR_FOG_START) return 0; - return Math.clamp((d - AIR_FOG_START) / (AIR_FOG_END - AIR_FOG_START), 0, AIR_FOG_MAX); - } if (d <= fogStart) return 0; - if (d >= fogEnd) return 1; - return (d - fogStart) / (fogEnd - fogStart); + if (d >= fogEnd) return fogMax; + return Math.min((d - fogStart) / (fogEnd - fogStart), fogMax); } - /** {@code color} fogged at distance {@code d}; air fogs towards {@code skyColor}. */ + /** {@code color} fogged at distance {@code d}; overworld air fogs towards {@code skyColor}. */ int fog(int color, double d, int skyColor) { double f = fogValue(d); - return f > 0 ? ColorUtil.mix(color, inFluid() ? fogColor : skyColor, 1 - f, f) : color; + return f > 0 ? ColorUtil.mix(color, fogColor >= 0 ? fogColor : skyColor, 1 - f, f) : color; } /** Whether {@link #fog}/{@link #sky} of a surface at {@code t} (infinite = sky) read the sky colour. */ boolean needsSkyColor(double t) { - return Double.isInfinite(t) || (!inFluid() && t > AIR_FOG_START); + return Double.isInfinite(t) || (fogColor < 0 && t > fogStart); } /** diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/SnapshotRaytracer.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/SnapshotRaytracer.java index c3d3c4a..a81feaa 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/SnapshotRaytracer.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/SnapshotRaytracer.java @@ -86,8 +86,8 @@ public final class SnapshotRaytracer { * The medium (air or {@code fluid} with its fog) of the camera at {@code origin}; once per photo. {@code fluid} = * {@link FluidRenderer#cameraFluid} (determined once per photo, also for the FOV). */ - public CameraMedium cameraMedium(WorldSnapshot snapshot, Vector origin, int fluid) { - return CameraMedium.at(snapshot, origin, fluid, tintProvider); + public CameraMedium cameraMedium(WorldSnapshot snapshot, Vector origin, int fluid, SkyContext sky) { + return CameraMedium.at(snapshot, origin, fluid, tintProvider, sky); } public int trace( diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java index 0dbbadd..2f3f09a 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java @@ -25,6 +25,7 @@ import eu.mhsl.minecraft.pixelpics.render.snapshot.FrustumBounds; import eu.mhsl.minecraft.pixelpics.render.snapshot.ObjectSnapshotBuilder; import eu.mhsl.minecraft.pixelpics.render.snapshot.SnapshotBuilder; import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot; +import eu.mhsl.minecraft.pixelpics.render.tint.BiomeClimate; import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider; import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil; import eu.mhsl.minecraft.pixelpics.render.util.SpecialDates; @@ -54,6 +55,9 @@ public class DefaultScreenRenderer { private static final double FLUID_FOV_SCALE = 6.0 / 7.0; private static final double MAX_DISTANCE = 256; + // Fog colours of the vanilla 26.3 dimension types / nether_wastes (the default of a nether biome without one). + private static final int NETHER_FOG_COLOR = 0x330808; + private static final int END_FOG_COLOR = 0x181318; private static final int REFLECTION_DEPTH = 4; /** SSAA x SSAA rays per output pixel, downsampled gamma-correctly. */ @@ -126,7 +130,18 @@ public class DefaultScreenRenderer { long dayTime = world.getTime(); long fullTime = world.getFullTime(); int moonPhase = (int) (fullTime / 24000L % 8L); - SkyContext sky = new SkyContext(dayTime, moonPhase, fullTime); + SkyContext sky = + switch (world.getEnvironment()) { + case NETHER -> new SkyContext( + dayTime, + moonPhase, + fullTime, + SkyContext.Skybox.NONE, + BiomeClimate.fogColor( + world.getBiome(eyeLocation).getKey().getKey(), NETHER_FOG_COLOR)); + case THE_END -> new SkyContext(dayTime, moonPhase, fullTime, SkyContext.Skybox.END, END_FOG_COLOR); + default -> new SkyContext(dayTime, moonPhase, fullTime); + }; return new RenderJob( snapshot, @@ -191,7 +206,7 @@ public class DefaultScreenRenderer { job.objects(), objectBaker)); - CameraMedium medium = raytracer.cameraMedium(snapshot, origin, job.cameraFluid()); + CameraMedium medium = raytracer.cameraMedium(snapshot, origin, job.cameraFluid(), sky); BiomeBlend tints = raytracer.biomeBlend(snapshot); int[] superBuf = new int[rayMap.size()]; runParallel(() -> IntStream.range(0, rayMap.size()).parallel().forEach(i -> { diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/sky/SkyContext.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/sky/SkyContext.java index a4d02db..ea4826c 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/sky/SkyContext.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/sky/SkyContext.java @@ -2,7 +2,23 @@ package eu.mhsl.minecraft.pixelpics.render.sky; /** * Per-render sky state captured on the main thread: the world time of day (0..24000), the moon phase - * (0..7) and the absolute world time (for continuous cloud drift). Immutable so it can be read from - * worker threads. + * (0..7), the absolute world time (for continuous cloud drift), the dimension's sky box and its fog colour (Nether: + * the camera biome's {@code fog_color}, End: the dimension's). Immutable so it can be read from worker threads. */ -public record SkyContext(long dayTime, int moonPhase, long fullTime) {} +public record SkyContext(long dayTime, int moonPhase, long fullTime, Skybox skybox, int fogColor) { + + /** The dimension's sky like vanilla's {@code DimensionType.skybox}. */ + public enum Skybox { + /** Overworld sky: gradient, sun, moon, stars and clouds. */ + OVERWORLD, + /** No sky (the Nether): only the fog colour. */ + NONE, + /** The End's textured sky box. */ + END + } + + /** An overworld sky. */ + public SkyContext(long dayTime, int moonPhase, long fullTime) { + this(dayTime, moonPhase, fullTime, Skybox.OVERWORLD, 0); + } +} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/sky/SkyRenderer.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/sky/SkyRenderer.java index 15de5d8..508eeda 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/sky/SkyRenderer.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/sky/SkyRenderer.java @@ -6,8 +6,9 @@ import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil; import org.bukkit.util.Vector; /** - * Sky colour of rays that escape the world (not vanilla's sky renderer): day/night gradient with twilight glow, sun - * and moon (with phase), stars and vanilla's cloud layer. Immutable, so thread safe. + * Sky colour of rays that escape the world. Overworld (not vanilla's sky renderer): day/night gradient with twilight + * glow, sun and moon (with phase), stars and vanilla's cloud layer. The Nether has no sky (fog colour) and the End + * vanilla's textured sky box ({@code SkyRenderer.buildEndSky}). Immutable, so thread safe. */ public final class SkyRenderer { @@ -29,14 +30,71 @@ public final class SkyRenderer { private static final int SUNSET_RED = rgb(205, 70, 60); private static final int TWI_PURPLE = rgb(80, 42, 92); + // End sky box (vanilla SkyRenderer.buildEndSky): 6 quads at distance 100, the texture repeated 16 times per face, + // vertex colour #282828. + private static final double END_SKY_REPEAT = 16; + private static final int END_SKY_TINT = 0x28; + // Face poses of the end sky quads: identity, rotX(90°), rotX(-90°), rotX(180°), rotZ(90°), rotZ(-90°). + private static final double[][] END_FACES = { + {0, 0}, {1, Math.PI / 2}, {1, -Math.PI / 2}, {1, Math.PI}, {2, Math.PI / 2}, {2, -Math.PI / 2} + }; + private final int[][] moonTexture; private final int[][] cloudTexture; + private final int[][] endSkyTexture; public SkyRenderer(TextureCache textures) { this.moonTexture = textures.get(ResourceLocation.parse("environment/moon_phases")).orElse(null); this.cloudTexture = textures.get(ResourceLocation.parse("environment/clouds")).orElse(null); + this.endSkyTexture = + textures.get(ResourceLocation.parse("environment/end_sky")).orElse(null); + } + + /** + * The end sky in a direction: the quad of vanilla's sky box hit by the ray, its texel (nearest, repeating) times + * the vertex colour. + */ + private int endSky(double dx, double dy, double dz) { + int[][] tex = endSkyTexture; + if (tex == null || tex.length == 0) return rgb(END_SKY_TINT, END_SKY_TINT, END_SKY_TINT); + // The quad whose pose maps its plane y = -100 onto the ray: q = pose^T d with the most negative q.y. + double bestY = 0, qx = 0, qz = 0; + for (double[] face : END_FACES) { + double c = Math.cos(face[1]), sn = Math.sin(face[1]); + double x, y, z; + if (face[0] == 1) { // rotX(a)^T + x = dx; + y = dy * c + dz * sn; + z = -dy * sn + dz * c; + } else if (face[0] == 2) { // rotZ(a)^T + x = dx * c + dy * sn; + y = -dx * sn + dy * c; + z = dz; + } else { + x = dx; + y = dy; + z = dz; + } + if (y < bestY) { + bestY = y; + qx = x; + qz = z; + } + } + if (bestY >= 0) return rgb(END_SKY_TINT, END_SKY_TINT, END_SKY_TINT); + double t = -100 / bestY; + double u = (qx * t + 100) / 200 * END_SKY_REPEAT; + double v = (qz * t + 100) / 200 * END_SKY_REPEAT; + int h = tex.length, w = tex[0].length; + int px = Math.floorMod((int) Math.floor(u * w), w); + int py = Math.floorMod((int) Math.floor(v * h), h); + int texel = tex[py][px]; + return rgb( + ColorUtil.red(texel) * END_SKY_TINT / 255, + ColorUtil.green(texel) * END_SKY_TINT / 255, + ColorUtil.blue(texel) * END_SKY_TINT / 255); } public int colorFor(Vector direction, Vector origin, SkyContext ctx) { @@ -46,6 +104,15 @@ public final class SkyRenderer { dx /= len; dy /= len; dz /= len; + switch (ctx.skybox()) { + case NONE -> { + return ctx.fogColor() & 0xFFFFFF; + } + case END -> { + return endSky(dx, dy, dz); + } + case OVERWORLD -> {} + } // Vanilla sun direction: rotated by celestialAngle·360° about X (after -90° about Y) // = (-sin(2π·ca), cos(2π·ca), 0). diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeClimate.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeClimate.java index 1021d1c..ff38e6b 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeClimate.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeClimate.java @@ -188,6 +188,39 @@ public final class BiomeClimate { private BiomeClimate() {} + // Biome attribute minecraft:visual/fog_color of the vanilla 26.3 nether biomes (fallback without server data). + private static final Map FOG_FALLBACK = Map.of( + "nether_wastes", 0x330808, + "basalt_deltas", 0x685F70, + "crimson_forest", 0x330303, + "warped_forest", 0x1A051A, + "soul_sand_valley", 0x1B4745); + private static final Map FOG_LOADED = new ConcurrentHashMap<>(); + + /** The biome's {@code minecraft:visual/fog_color} attribute applied to {@code base}. */ + public static int fogColor(String biomePath, int base) { + int loaded = FOG_LOADED.computeIfAbsent(biomePath, BiomeClimate::loadFogColor); + if (loaded != NONE) return loaded == Integer.MIN_VALUE ? base : loaded; + return FOG_FALLBACK.getOrDefault(biomePath, base); + } + + /** The fog colour of the biome file (MIN_VALUE: file without one), NONE without server data. */ + private static int loadFogColor(String biomePath) { + if (!biomePath.matches("[a-z0-9_./-]+")) return NONE; + ClassLoader loader = BiomeClimate.class.getClassLoader(); + if (loader == null) return NONE; + try (InputStream in = loader.getResourceAsStream(DATA_PATH + biomePath + ".json")) { + if (in == null) return NONE; + JsonObject root = JsonParser.parseReader(new InputStreamReader(in, StandardCharsets.UTF_8)) + .getAsJsonObject(); + JsonObject attributes = root.has("attributes") ? root.getAsJsonObject("attributes") : new JsonObject(); + if (!attributes.has("minecraft:visual/fog_color")) return Integer.MIN_VALUE; + return rgbAttribute(attributes, "minecraft:visual/fog_color", 0); + } catch (Exception e) { + return NONE; + } + } + public static Climate forKey(String biomePath) { Optional loaded = LOADED.computeIfAbsent(biomePath, BiomeClimate::loadFromServerData); return loaded.isPresent() ? loaded.get() : FALLBACK.getOrDefault(biomePath, DEFAULT);