diff --git a/README.md b/README.md index e75afd5..00bafe2 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,9 @@ La [vision complète](docs/vision.md) conserve les intentions ; le L’économie, les claims et les dimensions décrits dans la vision restent à implémenter. -La version **beta.144** prépare une option d’**eau naturelle pixélisée** : -couleur du biome, trois profils selon le fond et visibilité adaptée en immersion. +La version **beta.144** ajoute une option d’**eau naturelle pixélisée** : +couleur du biome, trois profils selon le fond, vert olive d’étang et visibilité +adaptée en immersion. Les océans gardent leur couleur native et le profil clair. [Contrat et vérifications](docs/natural-water-beta144.md). Livraison en cours. diff --git a/docs/natural-water-beta144.md b/docs/natural-water-beta144.md index dbcb43e..25289da 100644 --- a/docs/natural-water-beta144.md +++ b/docs/natural-water-beta144.md @@ -29,7 +29,9 @@ vert olive légèrement jaunâtre : le profil étang passe donc de 45 % à 85 % de mélange, tout en laissant les eaux claires et océaniques natives. Les surfaces profondes sans fond dans la limite restent donc claires. Les profils se mélangent aux sommets sur un bloc, sans filtrer les -textures. L’eau sous un toit suit son fond, pas l’éclairage du bâtiment. +textures. Aux bords océaniques, les sommets partagés restent clairs et la +transition occupe le bloc voisin hors océan. L’eau sous un toit suit son fond, +pas l’éclairage du bâtiment. La ressource client `assets/sanctuary/water_materials.json` accepte deux listes d’identifiants de blocs, `light` et `pond`, remplaçables par un resource pack. @@ -44,7 +46,9 @@ et tri des transparences restent natifs. Les calculs de profils sont différés jusqu’à l’émission d’un sommet visible, puis mutualisés dans la région compilée. Un test de palette élimine les sections sans eau avant toute copie de biomes. Les états de blocs viennent des copies natives ; les appartenances océaniques -sont capturées avant l’envoi au worker. Aucun chargement de chunk n’est demandé. +sont capturées avant l’envoi au worker. La présence du terrain est vérifiée dans le cache client avec chargement +désactivé, sans utiliser `ClientLevel.hasChunk` (toujours vrai). Aucun +chargement de chunk n’est demandé par le classificateur. Un changement de bloc invalide la zone dépendante, huit blocs au-dessus et un bloc horizontalement. Les reconstructions natives recopient leurs données ; @@ -68,30 +72,66 @@ sable et boue, support du sable, plantes aquatiques, toit, frontière de section et changement de biome océan. Une copie personnelle de ressource remplace réellement les familles de matériaux puis est désactivée. -Les suites graphiques OpenGL et Vulkan ont passé les contrôles de surface, -d’immersion, de fond modifié, d’océan, de ressources, de préférences et de -cascades pendant le développement. La dernière optimisation reconstruit -seulement les sections contenant de l’eau lors du changement d’option. -Sa série de validation finale reste à terminer avant publication. +Les suites graphiques finales OpenGL et Vulkan sont réussies le 18 septembre +2026 (`WATER144_PASS` et `WATER144_EXTRAS_PASS`). Elles couvrent surface, +immersion, fond modifié, priorité océan, toit, plantes, cascades, lave inchangée, +blocs gorgés d’eau, préférences FR/EN et rechargement des ressources/chunks. +Les sommets partagés aux limites océaniques et le repli clair lorsque le cache +client ne contient pas le terrain font l’objet d’assertions supplémentaires. +Aucun autre client de test concurrent n’a été détecté pendant ces séries. +Deux passages ciblés supplémentaires (`WATER144_TEXTURE_PASS`), Vulkan en +1 min 18 s et OpenGL en 1 min 4 s, chargent de vraies textures personnelles +32×32 à deux images animées pour l’eau calme et courante. Grille, animation +visible et retour aux textures natives sont vérifiés après désactivation. +Les fixtures, leurs alpha et les captures restent dans les preuves locales. ### Protocole de performance -Apple M1, macOS, Java 25, 854×480, distance de rendu 6 chunks, +MacBookPro17,1, Apple M1, 8 Gio, macOS 26.5.1 (25F80), Java 25, +fenêtre logique 854×480, cible Retina **1708×960 pixels**, distance de rendu 6 chunks, ombres 32 blocs / 16 pixels, PBR 50 %, autres effets activés. VSync désactivée, limite 260 (illimitée), limitation AFK désactivée. +Le mod de test vérifie le limiteur effectif et journalise le mode de présentation. +Pendant les échantillons uniquement, il omet l’attente artificielle de +`FramerateLimiter.limitDisplayFPS`, afin de mesurer sans temporisation +applicative. Cette sonde reste absente du JAR de production. Les temps couvrent +la frame native, présentation comprise, et ne sont pas des temps GPU isolés. Pour chaque scène : OFF/ON/ON/OFF/ON/OFF/OFF/ON, dix secondes par passage, avec stabilisation des sections puis quatre secondes de chauffe entre passages. Le déplacement avance de 120 blocs et laisse les sections se compiler naturellement, sans vidange artificielle des caches des autres effets. +La scène de déplacement prolonge le bassin hors de la vue initiale : son terrain +est préparé côté serveur de test, puis découvert normalement par le client. +Le vérificateur exige des compilations réelles dans chaque passage en mouvement. Les moyennes des quatre médianes et P95 par état sont comparées avec les seuils +5 % et +2 ms. Les temps de compilation de sections sont consignés également. -Les essais courts initiaux ont montré une forte variabilité. Une limitation AFK -à 30 FPS puis des clients Minecraft concurrents dans le chantier PBR/SSR ont -été identifiés. Ces essais restent dans les preuves de développement ; ils -ne valident pas la livraison. La série finale attend la fin du chantier -concurrent, conformément à la demande du créateur. Les résultats ne sont pas -présentés comme acquis, et aucun gain de performance universel n’est promis. +Les essais courts initiaux, les exécutions concurrentes et les vérifications +incomplètes du limiteur restent conservés comme preuves de développement ; +ils ne fondent pas ces résultats. Les séries finales ont été exécutées après +la fin du chantier SSR. Les variations négatives sont du bruit de mesure, +pas une promesse d’accélération. Vulkan reste proche de 120 images/s dans +cette chaîne de présentation, malgré le mode `IMMEDIATE` et l’absence de +limiteur applicatif : les conclusions sont limitées à la machine et aux +conditions ci-dessus. + +| Moteur / scène | Médiane OFF → ON (ms) | Variation | P95 OFF → ON (ms) | Δ P95 (ms) | +| --- | --- | ---: | --- | ---: | +| vulkan / mixed | 8.069 → 8.092 | +0.29 % | 9.091 → 9.101 | +0.010 | +| vulkan / large | 8.078 → 8.062 | -0.20 % | 9.126 → 9.114 | -0.012 | +| vulkan / moving | 8.123 → 8.103 | -0.24 % | 9.103 → 9.080 | -0.023 | +| opengl / mixed | 4.545 → 4.407 | -3.02 % | 7.080 → 6.733 | -0.347 | +| opengl / large | 4.879 → 5.110 | +4.73 % | 7.519 → 7.767 | +0.248 | +| opengl / moving | 6.817 → 6.338 | -7.02 % | 10.618 → 9.759 | -0.859 | + +Les six comparaisons respectent les seuils : médiane ≤ +5 %, P95 ≤ +2 ms. +Le cas limitant est la grande surface OpenGL (+4,73 %, +0,248 ms). +Les compilations de sections sont mesurées sur le parcours en mouvement : + +| Moteur | Sections OFF / ON | Médiane OFF → ON (ms) | P95 OFF → ON (ms) | +| --- | ---: | --- | --- | +| vulkan | 222 / 220 | 1.889 → 2.186 | 4.307 → 4.898 | +| opengl | 222 / 220 | 1.528 → 1.540 | 5.492 → 5.227 | Pour reproduire, avec Java 25 et un environnement graphique disponible : @@ -115,7 +155,7 @@ assembleTestPack -x :sanctuary:runGameTest` ; la suite complète non exclue n’est pas déclarée réussie. Les contrôles natifs de l’eau utilisent le serveur intégré d’un monde jetable. Aucun essai Windows n’est revendiqué. -L’assemblage avec l’exclusion ci-dessus a réussi en 3 min 24 s (126 tâches). +L’assemblage avec l’exclusion ci-dessus a réussi en 2 min 33 s (126 tâches). `python3 scripts/pack.py check` réussit. Les cinq archives passent le contrôle ZIP ; les deux MRpack embarquent exactement le même JAR Sanctuary et déclarent Fabric API 0.160.5+26.3 avec l’empreinte du manifeste source. L’export MRpack est @@ -131,8 +171,19 @@ Les preuves détaillées restent dans les dossiers ignorés : | Artefact | Octets | SHA-256 | | --- | ---: | --- | -| `sanctuary-beta.144.jar` | 11343178 | `56d7a92cdb4dc5897f3fe66b805cf74bc225cc3c0fc558facdb821d0756f7e27` | -| `Sanctuary-beta.144.mrpack` | 10483117 | `8118ad71989d7a1ba0ec2abe207599ca517cd508904003491fd35c8a378c5038` | -| `Sanctuary-Test-beta.144.mrpack` | 10502040 | `46915113ce06336ffa92e30e2e01b4c9afbd9ba499fe2d27e1ee03a197149fbe` | +| `sanctuary-beta.144.jar` | 11343257 | `1ddbe941f3262da8a1497ea5df4a992e260b80ae95b341623dd68f8f9729ae88` | +| `Sanctuary-beta.144.mrpack` | 10483196 | `d5ef5a781f5ee796117210d1f6afc96172461315faee34097323beff541ce6f0` | +| `Sanctuary-Test-beta.144.mrpack` | 10502119 | `e7cc32ac19ae98ebbe7a9aac3dc08decdf09396c8175b597f87051399ff3812e` | | `Sanctuary-Template-beta.144.zip` | 4354678 | `e510c0040dc9053492715ac91381b7dbc5a6f7c7defbf81e7502d8e18dbb0ee6` | | `Sanctuary-Prism-auto-update.zip` | 92423 | `c0920823f40d709c14e074d8d949f2cc3681c24140bf86f21c3be345cfc3e1f6` | + +## Captures comparatives + +Pièces jointes de la release, issues du client Vulkan de validation : +[OFF](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.144/Sanctuary-beta.144-water-off.png), +[ON](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.144/Sanctuary-beta.144-water-on.png), +[profil étang en proximité](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.144/Sanctuary-beta.144-water-pond.png). +Les captures sont exportées à 854×480 ; la cible de présentation mesurée est +1708×960. Le fichier public `Sanctuary-beta.144-SHA256SUMS.txt` inclut les cinq +livrables et ces trois captures. Les journaux et échantillons détaillés restent +locaux, dans `build/water144-evidence/` et les reçus `build/water144-*.json`. diff --git a/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/shader/Water144ClientChecks.java b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/shader/Water144ClientChecks.java index cf3f110..bf17365 100644 --- a/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/shader/Water144ClientChecks.java +++ b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/shader/Water144ClientChecks.java @@ -26,6 +26,7 @@ final class Water144ClientChecks { }); camera(c,server,72,125,16,70);c.waitTicks(50);settle(c); if(Boolean.getBoolean("sanctuary.test.water144ExtrasOnly")){ + resourceOverride(c); camera(c,server,88.5,96.2,8.5,70);look(c,server,0,0);c.waitTicks(20);capture(c,"pond-final-fog"); var green=c.computeOnClient(m->new org.joml.Vector4f(m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.fogData.color)); enable(c,false);var blue=c.computeOnClient(m->new org.joml.Vector4f(m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.fogData.color));capture(c,"native-final-fog"); @@ -39,6 +40,11 @@ final class Water144ClientChecks { check(NaturalWater.sample(m.level,88.5,99.1,8.5).equals(NaturalWater.POND),"Mud pond"); check(NaturalWater.sample(m.level,84.5,99.1,8.5).equals(NaturalWater.POND),"Seagrass is not a floor"); check(NaturalWater.sample(m.level,88.5,110.1,8.5).equals(NaturalWater.CLEAR),"No bed in bounded search uses clear"); + var unloaded=new BlockPos(1_000_016,96,1_000_016); + check(!NaturalWater.loaded(m.level,unloaded.getX()>>4,unloaded.getZ()>>4),"Fixture region is unloaded"); + var missing=new NaturalWater.Region(m.level,62500,5,62500); + check(missing.corner(null,unloaded,0,0).equals(NaturalWater.CLEAR),"Unavailable biome snapshot forces clear without reading missing terrain"); + check(!NaturalWater.loaded(m.level,unloaded.getX()>>4,unloaded.getZ()>>4),"Classification does not load chunks"); var boundary=NaturalWater.sample(m.level,80,99.1,8.5);check(boundary.tint()>.1&&boundary.tint()<.85,"Section boundary mixes profiles"); }); enable(c,false);var off=capture(c,"surface-off");enable(c,true);var on=capture(c,"surface-on"); @@ -66,7 +72,20 @@ final class Water144ClientChecks { check(Math.abs(pondFog/nativeFog-.4)<.01,"Rendered underwater fog distance matches pond profile");capture(c,"immersed-native");enable(c,true); camera(c,server,72,125,16,70); server.runOnServer(s->command(s,"fillbiome 80 88 0 95 104 32 minecraft:ocean"));c.waitTicks(35);settle(c); - c.runOnClient(m->check(NaturalWater.sample(m.level,88.5,99.1,8.5).equals(NaturalWater.CLEAR),"Ocean overrides muddy bed"));capture(c,"ocean-over-mud"); + c.runOnClient(m->{ + check(NaturalWater.sample(m.level,88.5,99.1,8.5).equals(NaturalWater.CLEAR),"Ocean overrides muddy bed"); + boolean found=false; + for(int x=72;x<89;x++){ + var p=new BlockPos(x,99,8); + if(!NaturalWater.ocean(m.level,p)&&NaturalWater.ocean(m.level,p.east())){ + var left=new NaturalWater.Region(m.level,(x>>4)-1,5,-1); + var right=new NaturalWater.Region(m.level,((x+1)>>4)-1,5,-1); + check(left.corner(null,p,1,0).equals(right.corner(null,p.east(),0,0)),"Ocean edge emits identical shared vertices"); + found=true;break; + } + } + check(found,"Native ocean boundary exercised"); + });capture(c,"ocean-over-mud"); server.runOnServer(s->command(s,"fillbiome 80 88 0 95 104 32 minecraft:plains"));c.waitTicks(30); var reload=c.computeOnClient(net.minecraft.client.Minecraft::reloadResourcePacks);c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();settle(c);capture(c,"resources-reloaded"); resourceOverride(c); @@ -113,36 +132,76 @@ final class Water144ClientChecks { camera(c,server,56.5,102,8.5,70);capture(c,"waterlogged-slab"); System.out.println("WATER144_EXTRAS_PASS falling water, unchanged lava and waterlogged slab"); } + private static net.minecraft.client.renderer.texture.SpriteContents waterSprite(net.minecraft.client.Minecraft m,String name){ + return m.getAtlasManager().getAtlasOrThrow(net.minecraft.data.AtlasIds.BLOCKS) + .getSprite(net.minecraft.resources.Identifier.withDefaultNamespace("block/"+name)).contents(); + } private static void resourceOverride(ClientGameTestContext c){ + int originalWidth=c.computeOnClient(m->waterSprite(m,"water_still").width()); + int originalFrames=c.computeOnClient(m->waterSprite(m,"water_still").getUniqueFrames().size()); var directory=c.computeOnClient(m->m.getResourcePackDirectory().resolve("water144-fixture")); try{ Files.createDirectories(directory.resolve("assets/sanctuary")); var mod=net.fabricmc.loader.api.FabricLoader.getInstance().getModContainer("sanctuary").orElseThrow(); Files.copy(mod.findPath("resourcepacks/template/pack.mcmeta").orElseThrow(),directory.resolve("pack.mcmeta"),StandardCopyOption.REPLACE_EXISTING); + var textures=directory.resolve("assets/minecraft/textures/block");Files.createDirectories(textures); + try(var pixels=new com.mojang.blaze3d.platform.NativeImage(32,64,false)){ + for(int y=0;y<64;y++)for(int x=0;x<32;x++)pixels.setPixel(x,y,((x/4+y/4+y/32)&1)==0?0xd0ffffff:0x80404040); + for(String name:new String[]{"water_still","water_flow"}){ + pixels.writeToFile(textures.resolve(name+".png")); + Files.writeString(textures.resolve(name+".png.mcmeta"),"{\"animation\":{\"frametime\":4,\"frames\":[0,1]}}"); + } + } Files.writeString(directory.resolve("assets/sanctuary/water_materials.json"),"{\"light\":[],\"pond\":[\"minecraft:quartz_block\"]}"); }catch(Exception e){throw new AssertionError(e);} c.runOnClient(m->{var repo=m.getResourcePackRepository();repo.reload();check(repo.addPack("file/water144-fixture"),"Custom materials pack discovered");}); var future=c.computeOnClient(net.minecraft.client.Minecraft::reloadResourcePacks);c.waitFor(m->future.isDone()&&m.gui.overlay()==null,1200);future.join();settle(c); c.runOnClient(m->{check(NaturalWater.sample(m.level,56.5,99.1,8.5).equals(NaturalWater.POND),"Resource pack replaces material family"); check(NaturalWater.sample(m.level,88.5,99.1,8.5).equals(NaturalWater.CLEAR),"Replacement removes bundled assignment");}); - capture(c,"custom-materials"); + c.runOnClient(m->{for(String name:new String[]{"water_still","water_flow"}){ + var sprite=waterSprite(m,name);check(sprite.width()==32&&sprite.height()==32&&sprite.getUniqueFrames().size()==2,"Custom pixel grid and animation loaded: "+name); + }}); + var first=capture(c,"custom-materials");long animated=0; + for(int i=0;i<8;i++){c.waitTicks(1);animated=Math.max(animated,difference(first,capture(c,"custom-animation-"+i)));} + check(animated>100000,"Custom water animation remains visible"); c.runOnClient(m->m.getResourcePackRepository().removePack("file/water144-fixture")); var restore=c.computeOnClient(net.minecraft.client.Minecraft::reloadResourcePacks);c.waitFor(m->restore.isDone()&&m.gui.overlay()==null,1200);restore.join();settle(c); - c.runOnClient(m->check(NaturalWater.sample(m.level,88.5,99.1,8.5).equals(NaturalWater.POND),"Disabling custom pack restores defaults")); + c.runOnClient(m->{ + check(NaturalWater.sample(m.level,88.5,99.1,8.5).equals(NaturalWater.POND),"Disabling custom pack restores defaults"); + check(waterSprite(m,"water_still").width()==originalWidth&&waterSprite(m,"water_still").getUniqueFrames().size()==originalFrames,"Native water texture restored"); + }); + System.out.println("WATER144_TEXTURE_PASS custom 32px still/flow textures, alpha, two animation frames and restoration"); } private static void benchmark(ClientGameTestContext c,TestServerContext s){ int oldLimit=c.computeOnClient(m->m.options.framerateLimit().get()); var oldInactivity=c.computeOnClient(m->m.options.inactivityFpsLimit().get()); - c.runOnClient(m->{m.options.framerateLimit().set(net.minecraft.client.Options.UNLIMITED_FRAMERATE_CUTOFF);m.options.inactivityFpsLimit().set(net.minecraft.client.InactivityFpsLimit.MINIMIZED);}); + c.runOnClient(m->{m.options.framerateLimit().set(net.minecraft.client.Options.UNLIMITED_FRAMERATE_CUTOFF);m.options.inactivityFpsLimit().set(net.minecraft.client.InactivityFpsLimit.MINIMIZED);m.getFramerateLimitTracker().setFramerateLimit(net.minecraft.client.Options.UNLIMITED_FRAMERATE_CUTOFF);}); var results=new LinkedHashMap(); try { for(String scene:new String[]{"mixed","large","moving"}){ if(scene.equals("large")){s.runOnServer(server->{command(server,"fill 31 94 -17 112 100 -17 glass");command(server,"fill 31 94 48 112 100 48 glass"); command(server,"fill 31 94 -17 31 100 48 glass");command(server,"fill 112 94 -17 112 100 48 glass"); command(server,"fill 32 93 -16 111 93 47 quartz_block");command(server,"fill 32 94 -16 111 94 47 sand");command(server,"fill 32 95 -16 111 99 47 water");});c.waitTicks(50);} + if(scene.equals("moving")){ + // Test fixture only: prepare water beyond the initial client view so movement really compiles terrain. + s.runOnServer(server->{ + command(server,"forceload add 112 -17 288 48"); + for(int x=7;x<=18;x++)for(int z=-2;z<=3;z++)server.overworld().getChunk(x,z); + command(server,"fill 112 94 -17 288 100 -17 glass");command(server,"fill 112 94 48 288 100 48 glass"); + command(server,"fill 288 94 -17 288 100 48 glass"); + for(int x=112;x<288;x+=16){ + command(server,"fill "+x+" 93 -16 "+(x+15)+" 93 47 quartz_block"); + command(server,"fill "+x+" 94 -16 "+(x+15)+" 94 47 sand"); + command(server,"fill "+x+" 95 -16 "+(x+15)+" 99 47 water"); + } + });c.waitTicks(80); + } camera(c,s,72,125,16,70);settle(c); for(boolean enabled:new boolean[]{false,true,true,false,true,false,false,true}){ - camera(c,s,72,125,16,70);enable(c,enabled);c.waitTicks(80);c.runOnClient(m->Water144Metrics.start()); + camera(c,s,72,125,16,70);enable(c,enabled);c.waitTicks(80);c.runOnClient(m->{ + check(m.getFramerateLimitTracker().getFramerateLimit()==260&&!m.options.enableVsync().get(),"Uncapped native limiter and VSync disabled"); + System.out.println("WATER144_CONDITIONS limit="+m.getFramerateLimitTracker().getFramerateLimit()+" throttle="+m.getFramerateLimitTracker().getThrottleReason()+" renderLimit="+m.gameRenderer.gameRenderState().framerateLimit+" surface="+m.windowSurface().currentConfiguration()+" limiterBypass=true"); + Water144Metrics.start();}); if(scene.equals("moving")){ for(int i=0;i<200;i++){final int step=i;c.runOnClient(m->{m.player.setPos(48+step*.6,125,16);m.player.setDeltaMovement(net.minecraft.world.phys.Vec3.ZERO);});c.waitTicks(1);} }else c.waitTicks(200); @@ -151,6 +210,6 @@ final class Water144ClientChecks { } } try{Files.writeString(Path.of("water144-performance.json"),new com.google.gson.GsonBuilder().setPrettyPrinting().create().toJson(results));}catch(Exception e){throw new AssertionError(e);} - }finally{c.runOnClient(m->{m.options.framerateLimit().set(oldLimit);m.options.inactivityFpsLimit().set(oldInactivity);});} + }finally{c.runOnClient(m->{m.options.framerateLimit().set(oldLimit);m.getFramerateLimitTracker().setFramerateLimit(oldLimit);m.options.inactivityFpsLimit().set(oldInactivity);});} } } diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/mixin/Water144FrameProbeMixin.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/mixin/Water144FrameProbeMixin.java index 232e0e5..51953a6 100644 --- a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/mixin/Water144FrameProbeMixin.java +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/mixin/Water144FrameProbeMixin.java @@ -1,5 +1,6 @@ package fr.koka.sanctuary.gametest.mixin; import fr.koka.sanctuary.gametest.Water144Metrics; +import com.llamalad7.mixinextras.injector.wrapoperation.*; import net.minecraft.client.Minecraft; import org.spongepowered.asm.mixin.*; import org.spongepowered.asm.mixin.injection.*; @@ -7,6 +8,10 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; @Mixin(Minecraft.class) abstract class Water144FrameProbeMixin { @Unique private long sanctuary$start; + // Benchmark only: the extracted frame state can retain a 120 FPS limit despite the option/tracker. + @WrapOperation(method="renderFrame",at=@At(value="INVOKE",target="Lnet/minecraft/client/FramerateLimiter;limitDisplayFPS(I)V")) + private void sanctuary$uncapped(int fps,Operation original){if(!Water144Metrics.recording)original.call(fps);} + @Inject(method="renderFrame",at=@At("HEAD")) private void start(boolean tick,CallbackInfo ci){if(Water144Metrics.recording)sanctuary$start=System.nanoTime();} @Inject(method="renderFrame",at=@At("RETURN")) private void end(boolean tick,CallbackInfo ci){if(Water144Metrics.recording)Water144Metrics.frame(System.nanoTime()-sanctuary$start);} } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/NaturalWater.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/NaturalWater.java index 1f4e200..ad1351c 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/NaturalWater.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/NaturalWater.java @@ -81,7 +81,11 @@ public final class NaturalWater { } public static float strength(){return strength;} public static Profile fogProfile(){return fogFrom.mix(fogTarget,Math.clamp((System.nanoTime()-fogChanged)/500_000_000f,0,1));} - public static boolean ocean(ClientLevel level,BlockPos pos){return level.hasChunk(pos.getX()>>4,pos.getZ()>>4)&&level.getBiome(pos).is(BiomeTags.IS_OCEAN);} + // ClientLevel.hasChunk always returns true; inspect the cache without requesting a chunk. + public static boolean loaded(ClientLevel level,int x,int z){ + return level.getChunkSource().getChunk(x,z,net.minecraft.world.level.chunk.status.ChunkStatus.FULL,false)!=null; + } + public static boolean ocean(ClientLevel level,BlockPos pos){return loaded(level,pos.getX()>>4,pos.getZ()>>4)&&level.getBiome(pos).is(BiomeTags.IS_OCEAN);} private static int classify(java.util.function.Function read,BlockPos pos,Map table){ var cursor=pos.mutable(); for(int i=1;i<=DEPTH;i++){ @@ -102,7 +106,7 @@ public final class NaturalWater { return live(level,bx,by,bz).mix(live(level,bx+1,by,bz),dx).mix(live(level,bx,by,bz+1).mix(live(level,bx+1,by,bz+1),dx),dz); } private static Profile live(ClientLevel l,int x,int y,int z){ - var p=new BlockPos(x,y,z);if(!l.hasChunk(x>>4,z>>4)||ocean(l,p))return CLEAR; + var p=new BlockPos(x,y,z);if(!loaded(l,x>>4,z>>4)||ocean(l,p))return CLEAR; return PROFILES[classify(l::getBlockState,p,materials)]; } /** Owned by a single native section compilation, with ocean data captured before worker submission. */ @@ -120,13 +124,14 @@ public final class NaturalWater { var copied=new java.util.ArrayList>(216); int qx=(ox>>2)-1,qy=(oy>>2)-1,qz=(oz>>2)-1; for(int y=0;y<6;y++)for(int z=0;z<6;z++)for(int x=0;x<6;x++) - copied.add(level.hasChunk((qx+x)>>2,(qz+z)>>2)?level.getBiomeManager().getNoiseBiomeAtQuart(qx+x,qy+y,qz+z):null); + copied.add(loaded(level,(qx+x)>>2,(qz+z)>>2)?level.getBiomeManager().getNoiseBiomeAtQuart(qx+x,qy+y,qz+z):null); biomes=level.getBiomeManager().withDifferentSource((x,y,z)->copied.get(((y-qy)*6+z-qz)*6+x-qx)); } + // A missing biome snapshot also forces clear water at unloaded region edges. public boolean ocean(int x,int y,int z){ if(oceanKinds==null)oceanKinds=new byte[18*16*18]; int key=((y-oy)*18+z-oz+1)*18+x-ox+1; - int code=oceanKinds[key];if(code==0){var biome=biomes.getBiome(x,y,z);code=biome!=null&&biome.is(BiomeTags.IS_OCEAN)?2:1;oceanKinds[key]=(byte)code;} + int code=oceanKinds[key];if(code==0){var biome=biomes.getBiome(x,y,z);code=biome==null||biome.is(BiomeTags.IS_OCEAN)?2:1;oceanKinds[key]=(byte)code;} return code==2; } private Profile profile(BlockAndTintGetter region,int x,int y,int z){ @@ -140,6 +145,9 @@ public final class NaturalWater { public Profile corner(BlockAndTintGetter region,BlockPos p,int dx,int dz){ if(ocean(p.getX(),p.getY(),p.getZ()))return CLEAR; int x=p.getX()+dx-1,y=p.getY(),z=p.getZ()+dz-1; + // Both cells sharing an ocean edge must emit the same clear vertex. + // The one-block transition stays on the non-ocean side, preserving ocean priority. + if(ocean(x,y,z)||ocean(x+1,y,z)||ocean(x,y,z+1)||ocean(x+1,y,z+1))return CLEAR; return profile(region,x,y,z).mix(profile(region,x+1,y,z),.5f).mix(profile(region,x,y,z+1).mix(profile(region,x+1,y,z+1),.5f),.5f); } } diff --git a/scripts/check_water_performance.py b/scripts/check_water_performance.py index 8a3f611..d69f1e8 100644 --- a/scripts/check_water_performance.py +++ b/scripts/check_water_performance.py @@ -12,11 +12,14 @@ backend=log_path.stem log=log_path.read_text() rows={key:json.loads(data) for key,data in re.findall(r"WATER144_PERF (\S+) (\[[^\n]+\])",log)} assert len(rows)==24, ("Incomplete measurements",len(rows)) +assert log.count("WATER144_CONDITIONS limit=260 throttle=NONE")==24, "Measurements must be uncapped" result={"backend":backend,"samples":rows,"scenes":{}} for scene in ("mixed","large","moving"): off=[v for k,v in rows.items() if k.startswith(scene+"-false-")] on=[v for k,v in rows.items() if k.startswith(scene+"-true-")] assert len(off)==len(on)==4 and all(v[0]>50 for v in off+on) + if scene=="moving": + assert all(v[3]>0 for v in off+on), "Moving scene must compile terrain sections" median_off=sum(v[1] for v in off)/len(off);median_on=sum(v[1] for v in on)/len(on) p95_off=sum(v[2] for v in off)/len(off);p95_on=sum(v[2] for v in on)/len(on) value={"offMedianMs":median_off,"onMedianMs":median_on,"medianIncreasePercent":100*(median_on/median_off-1),"offP95Ms":p95_off,"onP95Ms":p95_on,"p95IncreaseMs":p95_on-p95_off}