diff --git a/README.md b/README.md index d44cb1b..e75afd5 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,11 @@ 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. +Les océans gardent leur couleur native et le profil clair. +[Contrat et vérifications](docs/natural-water-beta144.md). Livraison en cours. + La version **beta.143** garde le sampler natif de précipitation pour la neige saisonnière, mais ralentit son rythme : l'accumulation est espacée par un facteur 12 et la fonte par le facteur temps réel 72. Les protections existantes diff --git a/docs/natural-water-beta144.md b/docs/natural-water-beta144.md new file mode 100644 index 0000000..dbcb43e --- /dev/null +++ b/docs/natural-water-beta144.md @@ -0,0 +1,138 @@ +# WATER-144 — Eau naturelle pixélisée + +Branche `codex/water-beta144`, socle beta.143 publiée (`97ff2b5`) et suivi +`580b241`. Minecraft 26.3, Java 25, dépendances conservées. + +## Contrat + +**Options → Shaders → Eau naturelle** est activée par défaut avec Sanctuary. +L’intensité (0–100 %, défaut 100 %) agit sur la surface et l’immersion. +L’interrupteur ou 0 % retrouve le rendu précédent. Les préférences existantes +restent conservées ; un moteur externe suspend l’effet comme les autres effets +natifs Sanctuary. + +La couleur native du biome reste la base, y compris les variantes d’océans. +Les océans (`minecraft:is_ocean`) imposent le profil clair. +Hors océan, le premier support situé au maximum huit blocs sous la cellule +observée choisit le profil : + +| Profil | Fond | Mélange olive #788448 | Alpha natif | Distance de brume native | +| --- | --- | ---: | ---: | ---: | +| Claire | Pierre, matériaux construits, inconnus | 0 % | ×0,55 | ×1 | +| Légèrement teintée | Sable, sable rouge, gravier, argile | 10 % | ×0,80 | ×0,70 | +| Étang | Terre, terre stérile/enracinée, herbe, podzol, mycélium, boue, racines de palétuvier boueuses | 85 % | ×1 | ×0,40 | + +L’eau et les plantes aquatiques sans collision sont traversées. Un support +immergé reste un fond ; absence de support, air ou données manquantes donnent +une eau claire. La référence visuelle fournie pendant l’implémentation demande un véritable +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. + +La ressource client `assets/sanctuary/water_materials.json` accepte deux listes +d’identifiants de blocs, `light` et `pond`, remplaçables par un resource pack. +Les blocs absents des listes sont clairs. Une ressource invalide est signalée +et reprend les familles embarquées. Le template personnel inclut cette ressource. + +## Rendu et coût + +Modification des seules couleurs/alpha des sommets de `FluidRenderer`, sans +passe écran ni duplication de maillage. Textures, UV, animation, éclairage +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é. + +Un changement de bloc invalide la zone dépendante, huit blocs au-dessus et +un bloc horizontalement. Les reconstructions natives recopient leurs données ; +l’effet ne déclenche aucune reconstruction pour une lumière mobile. +Un changement d’option reconstruit seulement les sections chargées contenant de +l’eau ; les caches des autres effets restent disponibles. Le cache temporaire +disparaît avec sa compilation, sans historique de monde. + +Sous l’eau, le profil est échantillonné au maximum une fois par tick, avec +transition de 0,5 seconde. La couleur et les distances environnementales sont +adaptées dans le rendu natif du brouillard. La teinte finale est appliquée après +la normalisation de la vision aquatique, pour éviter de transformer l’olive en +jaune fluorescent ; la luminosité issue des modificateurs natifs de vision +et le bonus du compagnon calmar sont conservés. Lave et brume atmosphérique ne changent +pas. Il n’y a pas de simulation d’absorption volumétrique ni de reflet ajouté. + +## Validation native + +Les essais utilisent un monde plat jetable, graine 122, avec bassins sur quartz, +sable et boue, support du sable, plantes aquatiques, toit, frontière de sections +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. + +### Protocole de performance + +Apple M1, macOS, Java 25, 854×480, 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. +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. +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. + +Pour reproduire, avec Java 25 et un environnement graphique disponible : + +```sh +./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \ + -PsanctuaryPixelShadows122ClientTests=true -PsanctuaryWater144ClientTests=true \ + -PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true \ + -PsanctuaryClientGraphicsBackend=vulkan > build/water144-vulkan.log 2>&1 +python3 scripts/check_water_performance.py build/water144-vulkan.log +``` + +Répéter avec `opengl`, sans autre client graphique simultané. Le vérificateur +refuse les séries incomplètes et les dépassements ; il écrit le reçu détaillé +près du journal. Le marqueur `WATER144_PASS` atteste les contrôles fonctionnels. + +### Assemblage et publication + +Le GameTest serveur dédié reste exclu conformément au refus antérieur de son +EULA. La commande de livraison est `./gradlew check build assemblePack +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). +`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 +exécuté depuis chaque dossier de staging packwiz. Les dépendances imbriquées +Demeure et JEI sont vérifiées par l’assemblage natif du dépôt. + +La release, le canal et l’instance ne sont pas encore mis à jour. +Les preuves détaillées restent dans les dossiers ignorés : +`build/water144-*.log`, `build/water144-*-performance.json`, +`build/water144-*-isolation.json` et `build/water144-evidence/`. + +## Artefacts vérifié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-Template-beta.144.zip` | 4354678 | `e510c0040dc9053492715ac91381b7dbc5a6f7c7defbf81e7502d8e18dbb0ee6` | +| `Sanctuary-Prism-auto-update.zip` | 92423 | `c0920823f40d709c14e074d8d949f2cc3681c24140bf86f21c3be345cfc3e1f6` | diff --git a/docs/packwiz.md b/docs/packwiz.md index 8951f09..0a92b10 100644 --- a/docs/packwiz.md +++ b/docs/packwiz.md @@ -14,6 +14,14 @@ JAR Sanctuary sont des pièces jointes de releases Gitea. Aucun JAR de mod ou dépendance téléchargée n'entre dans l'historique Git. Le canal Beta est distinct de l'ancien pack 26.2. +## beta.144 — Eau naturelle pixélisée (préparation) + +Trois profils de couleur/transparence selon le fond, océans clairs et brume +aquatique adaptée. Le [ticket de livraison](natural-water-beta144.md) décrit +la teinte olive retenue, les contrôles et le protocole de performance. +La publication et la synchronisation attendent la validation finale isolée. +Les textures restent à beta.121 ; Minecraft 26.3 et les dépendances sont conservés. + ## beta.143 — Neige saisonnière en temps réel Accumulation espacée par un facteur 12 et fonte par le facteur temps réel 72, diff --git a/docs/shaders-beta040.md b/docs/shaders-beta040.md index 1e1204a..ea23404 100644 --- a/docs/shaders-beta040.md +++ b/docs/shaders-beta040.md @@ -107,3 +107,9 @@ Les contrôles ne revendiquent pas un essai avec Iris ou un autre moteur externe Les ombres portées pixélisées sont livrées dans [beta.127](shader-beta127.md), intégrées sur la beta.126. Leur préparation locale portait un numéro beta.122 non publié ; la release beta.122 existante n'est pas remplacée. + +## beta.144 — Eau naturelle + +Le [ticket WATER-144](natural-water-beta144.md) ajoute les profils clair, +légèrement teinté et étang au rendu natif des fluides et à leur brouillard. +La couleur du biome et les textures pixélisées restent la base. diff --git a/gradle.properties b/gradle.properties index f5825be..57c86fe 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,8 +9,8 @@ loom_version=1.17.20 fabric_api_version=0.160.5+26.3 # Release counter: beta.001, .002, .003, ... (see docs/versioning.md). -mod_version=beta.143 -pack_version=beta.143 +mod_version=beta.144 +pack_version=beta.144 resource_pack_version=beta.121 maven_group=fr.koka.sanctuary jei_version=30.32.0-sanctuary.3 diff --git a/mods/sanctuary/build.gradle b/mods/sanctuary/build.gradle index 577a118..5057098 100644 --- a/mods/sanctuary/build.gradle +++ b/mods/sanctuary/build.gradle @@ -34,6 +34,9 @@ if (clientRenderTests) { if (improvedTransparency.isPresent()) { systemProperty('sanctuary.test.improvedTransparency', improvedTransparency.get()) } + systemProperty('sanctuary.test.water144', providers.gradleProperty('sanctuaryWater144ClientTests').getOrElse('false')) + systemProperty('sanctuary.test.water144ExtrasOnly', providers.gradleProperty('sanctuaryWater144ExtrasOnly').getOrElse('false')) + systemProperty('sanctuary.test.water144PerfOnly', providers.gradleProperty('sanctuaryWater144PerfOnly').getOrElse('false')) systemProperty('sanctuary.test.pbr142', providers.gradleProperty('sanctuaryPbr142ClientTests').getOrElse('false')) systemProperty('sanctuary.test.directional141Only', providers.gradleProperty('sanctuaryDirectional141Only').getOrElse('false')) systemProperty('sanctuary.test.directional141', providers.gradleProperty('sanctuaryDirectional141ClientTests').getOrElse('false')) diff --git a/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/shader/PixelShadows122ClientChecks.java b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/shader/PixelShadows122ClientChecks.java index efd212c..516d39b 100644 --- a/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/shader/PixelShadows122ClientChecks.java +++ b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/shader/PixelShadows122ClientChecks.java @@ -151,6 +151,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest { c.runOnClient(m -> check(VanillaLight.active(), "Native Sanctuary shader is active without an external engine")); float morning = c.computeOnClient(m -> m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle); + if(Boolean.getBoolean("sanctuary.test.water144")){Water144ClientChecks.run(c,server);return;} if(coloredOnly){if(Boolean.getBoolean("sanctuary.test.directional141Only")){DirectionalLights141ClientChecks.run(c,server);return;}ColoredLights139ClientChecks.run(c,server);if(Boolean.getBoolean("sanctuary.test.colored140"))ColoredLights140ClientChecks.run(c,server);if(Boolean.getBoolean("sanctuary.test.directional141"))DirectionalLights141ClientChecks.run(c,server);return;} if(sunshaftOnly){Sunshafts137ClientChecks.run(c,server);return;} if(closeupOnly){SsgiCloseup135ClientChecks.run(c,server);Pbr134ClientChecks.run(c,server);return;} @@ -568,7 +569,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest { } static Frame capture(ClientGameTestContext c, String name) { - if (name != null) c.takeScreenshot("sanctuary-beta142-shader-" + name); + if (name != null) c.takeScreenshot((Boolean.getBoolean("sanctuary.test.water144")?"sanctuary-beta144-water-":"sanctuary-beta142-shader-") + name); var result = new CompletableFuture(); c.runOnClient(m -> Screenshot.takeScreenshot(m.gameRenderer.mainRenderTarget(), image -> { try (image) { 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 new file mode 100644 index 0000000..cf3f110 --- /dev/null +++ b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/shader/Water144ClientChecks.java @@ -0,0 +1,156 @@ +package fr.koka.sanctuary.client.shader; +import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*; +import fr.koka.sanctuary.gametest.Water144Metrics; +import net.fabricmc.fabric.api.client.gametest.v1.context.*; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.world.level.block.Blocks; +import java.nio.file.*; +import java.util.*; + +final class Water144ClientChecks { + static void run(ClientGameTestContext c,TestServerContext server){ + c.runOnClient(m->{ + VanillaLight.setEnabled(true);VanillaLight.setNaturalWater(true);VanillaLight.setWaterIntensity(100); + VanillaLight.setPbr(false);VanillaLight.setSsgi(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false); + VanillaLight.setPixelShadows(false);VanillaLight.setColoredLights(false);VanillaLight.setSunshafts(false);VanillaLight.setLensFlare(false);VanillaLight.setEdgeHighlights(false); + }); + server.runOnServer(s->{ + command(s,"fill 47 94 -1 96 95 33 quartz_block"); + command(s,"fill 47 96 -1 47 100 33 glass");command(s,"fill 96 96 -1 96 100 33 glass"); + command(s,"fill 47 96 -1 96 100 -1 glass");command(s,"fill 47 96 33 96 100 33 glass"); + command(s,"fill 64 95 0 79 95 32 sand");command(s,"fill 80 95 0 95 95 32 mud"); + command(s,"fill 48 96 0 95 99 32 water"); + command(s,"setblock 84 96 8 seagrass"); + for(int x=50;x<95;x+=4)command(s,"setblock "+x+" 96 20 gold_block"); + }); + camera(c,server,72,125,16,70);c.waitTicks(50);settle(c); + if(Boolean.getBoolean("sanctuary.test.water144ExtrasOnly")){ + 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"); + check(green.y>green.z&&green.y{ + check(NaturalWater.sample(m.level,56.5,99.1,8.5).equals(NaturalWater.CLEAR),"Constructed bed clear"); + check(NaturalWater.sample(m.level,72.5,99.1,8.5).equals(NaturalWater.LIGHT),"Sand lightly tinted"); + 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 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"); + check(difference(off,on)>100000,"Visible surface colour and opacity change"); + c.runOnClient(m->VanillaLight.setWaterIntensity(0));settle(c);var zero=capture(c,"surface-zero"); + check(difference(off,zero)VanillaLight.setWaterIntensity(100));settle(c); + camera(c,server,88.5,103,4.5,70);look(c,server,0,28);capture(c,"pond-closeup"); + camera(c,server,72,125,16,70); + server.runOnServer(s->command(s,"fill 80 95 0 95 95 32 quartz_block"));c.waitTicks(30);settle(c);var changed=capture(c,"bed-changed"); + check(difference(changed,on)>100000,"Changing bed four blocks below surface refreshes water"); + server.runOnServer(s->command(s,"fill 80 95 0 95 95 32 mud"));c.waitTicks(25);settle(c); + server.runOnServer(s->command(s,"fill 80 103 0 95 103 12 stone"));c.waitTicks(25);settle(c); + c.runOnClient(m->check(NaturalWater.sample(m.level,88.5,99.1,8.5).equals(NaturalWater.POND),"Roof does not replace floor rule"));capture(c,"covered-pond"); + server.runOnServer(s->command(s,"fill 80 103 0 95 103 12 air"));c.waitTicks(20); + for(int x:new int[]{56,72,88}){ + camera(c,server,x+.5,96.2,8.5,70);look(c,server,0,0);c.waitTicks(20);capture(c,"immersed-"+x); + c.runOnClient(m->{check(m.gameRenderer.mainCamera().getFluidInCamera()==net.minecraft.world.level.material.FogType.WATER,"Camera immersed"); + float expected=x==56?1:x==72?.7f:.4f;check(Math.abs(NaturalWater.fogProfile().fog()-expected)<.001,"Immersion uses corresponding profile");}); + } + var pondColor=c.computeOnClient(m->new org.joml.Vector4f(m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.fogData.color)); + check(pondColor.y>pondColor.z&&pondColor.y<.8f,"Olive fog avoids native neon normalization"); + float pondFog=c.computeOnClient(m->m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.fogData.environmentalEnd); + enable(c,false);float nativeFog=c.computeOnClient(m->m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.fogData.environmentalEnd); + 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"); + 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); + camera(c,server,400,120,16,70);c.waitTicks(30);camera(c,server,72,125,16,70);settle(c); + c.runOnClient(m->{check(NaturalWater.sample(m.level,88.5,99.1,8.5).equals(NaturalWater.POND),"Reloaded chunks keep correct profile"); + VanillaLight.setNaturalWater(false);VanillaLight.setWaterIntensity(37);forgetPreferences();check(!VanillaLight.naturalWater()&&VanillaLight.waterIntensity()==37,"Water preferences persist"); + VanillaLight.setNaturalWater(true);VanillaLight.setWaterIntensity(100); + for(String locale:new String[]{"fr_fr","en_us"}){m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager()); + for(String key:new String[]{"natural_water","natural_water_help","water_intensity","water_intensity_help","water_intensity_value"}){String full="sanctuary.shaders."+key;check(!Component.translatable(full,100).getString().equals(full),"FR/EN "+full);}} + });settle(c); + } + // All effects enabled; fixed inherited test quality (shadows 32 blocks, PBR 50%) for A/B comparison. + c.runOnClient(m->{VanillaLight.setPbr(true);VanillaLight.setSsgi(true);VanillaLight.setBloom(true);VanillaLight.setOreEmission(true); + VanillaLight.setPixelShadows(true);VanillaLight.setColoredLights(true);VanillaLight.setSunshafts(true);VanillaLight.setLensFlare(true);VanillaLight.setEdgeHighlights(true);}); + c.waitTicks(80);capture(c,"combined"); + benchmark(c,server); + extras(c,server); + System.out.println("WATER144_PASS surface, immersion, beds, ocean, section seam, roof, plants, invalidation, reload, preferences, FR/EN and combined rendering"); + } + private static void enable(ClientGameTestContext c,boolean value){c.runOnClient(m->VanillaLight.setNaturalWater(value));settle(c);} + private static void settle(ClientGameTestContext c){c.waitTicks(8);c.waitFor(m->m.levelRenderer.hasRenderedAllSections(),1200);c.waitTicks(20);} + private static long difference(Frame a,Frame b){long value=0;for(int i=0;i{ + for(int x:new int[]{100,108}){ + command(s,"fill "+x+" 95 0 "+(x+4)+" 104 16 glass"); + command(s,"fill "+(x+1)+" 96 1 "+(x+3)+" 103 15 air"); + command(s,"fill "+(x+1)+" 95 1 "+(x+3)+" 95 15 mud"); + } + command(s,"setblock 102 102 2 water");command(s,"setblock 110 102 2 lava"); + command(s,"setblock 56 99 8 oak_slab[waterlogged=true]"); + }); + c.waitTicks(80);camera(c,server,106,103,25,70);look(c,server,179,20);settle(c); + c.runOnClient(m->{ + var flow=m.level.getFluidState(new BlockPos(102,101,2));check(flow.is(net.minecraft.tags.FluidTags.WATER)&&!flow.isSource(),"Real falling water is rendered"); + check(m.level.getFluidState(new BlockPos(110,101,2)).is(net.minecraft.tags.FluidTags.LAVA),"Lava cascade remains lava"); + check(m.level.getBlockState(new BlockPos(56,99,8)).getValue(net.minecraft.world.level.block.state.properties.BlockStateProperties.WATERLOGGED),"Waterlogged slab preserved"); + }); + enable(c,false);var off=capture(c,"cascades-off");enable(c,true);var on=capture(c,"cascades-on"); + check(difference(off,on)>1000,"Waterfall responds to water option"); + int lava=0,stable=0;for(int i=0;i>16&255,g=a>>8&255,b=a&255; + if(r>160&&r>g*1.3&&b<90){lava++;if(channelDifference(a,on.pixels()[i])<70)stable++;}} + check(lava>100&&stable>lava*.8,"Lava colours remain native (animation tolerance)"); + 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 void resourceOverride(ClientGameTestContext c){ + 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); + 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->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")); + } + 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);}); + 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);} + 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()); + 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); + var values=c.computeOnClient(m->Water144Metrics.stop());String key=scene+"-"+enabled+"-"+results.size();results.put(key,values); + System.out.println("WATER144_PERF "+key+" "+Arrays.toString(values)); + } + } + 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);});} + } +} diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Water144Metrics.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Water144Metrics.java new file mode 100644 index 0000000..1d813b8 --- /dev/null +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Water144Metrics.java @@ -0,0 +1,11 @@ +package fr.koka.sanctuary.gametest; +import java.util.*; +public final class Water144Metrics { + public static volatile boolean recording; + private static final List frames=new ArrayList<>(),sections=new ArrayList<>(); + public static synchronized void frame(long ns){if(recording)frames.add(ns);} + public static synchronized void section(long ns){if(recording)sections.add(ns);} + public static synchronized void start(){frames.clear();sections.clear();recording=true;} + public static synchronized double[] stop(){recording=false;return new double[]{frames.size(),quantile(frames,.5),quantile(frames,.95),sections.size(),quantile(sections,.5),quantile(sections,.95)};} + private static double quantile(List values,double q){if(values.isEmpty())return 0;var sorted=values.stream().mapToLong(Long::longValue).sorted().toArray();return sorted[Math.min(sorted.length-1,(int)(sorted.length*q))]/1e6;} +} 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 new file mode 100644 index 0000000..232e0e5 --- /dev/null +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/mixin/Water144FrameProbeMixin.java @@ -0,0 +1,12 @@ +package fr.koka.sanctuary.gametest.mixin; +import fr.koka.sanctuary.gametest.Water144Metrics; +import net.minecraft.client.Minecraft; +import org.spongepowered.asm.mixin.*; +import org.spongepowered.asm.mixin.injection.*; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +@Mixin(Minecraft.class) +abstract class Water144FrameProbeMixin { + @Unique private long sanctuary$start; + @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/gametest/java/fr/koka/sanctuary/gametest/mixin/Water144SectionProbeMixin.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/mixin/Water144SectionProbeMixin.java new file mode 100644 index 0000000..ce849ad --- /dev/null +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/mixin/Water144SectionProbeMixin.java @@ -0,0 +1,17 @@ +package fr.koka.sanctuary.gametest.mixin; +import fr.koka.sanctuary.gametest.Water144Metrics; +import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import net.minecraft.client.renderer.chunk.*; +import net.minecraft.client.renderer.SectionBufferBuilderPack; +import net.minecraft.core.SectionPos; +import com.mojang.blaze3d.vertex.VertexSorting; +import org.spongepowered.asm.mixin.Mixin; +@Mixin(SectionCompiler.class) +abstract class Water144SectionProbeMixin { + @WrapMethod(method="compile") + private SectionCompiler.Results compile(SectionPos pos,RenderSectionRegion region,VertexSorting sorting,SectionBufferBuilderPack buffers,Operation original){ + boolean record=Water144Metrics.recording;long start=record?System.nanoTime():0; + try{return original.call(pos,region,sorting,buffers);}finally{if(record)Water144Metrics.section(System.nanoTime()-start);} + } +} diff --git a/mods/sanctuary/src/gametest/resources/sanctuary-gametest.mixins.json b/mods/sanctuary/src/gametest/resources/sanctuary-gametest.mixins.json index f637543..a025d4b 100644 --- a/mods/sanctuary/src/gametest/resources/sanctuary-gametest.mixins.json +++ b/mods/sanctuary/src/gametest/resources/sanctuary-gametest.mixins.json @@ -19,6 +19,8 @@ "Atlas026TextureProbeMixin", "Inventory013MerchantAccess", "Dynamic030TerrainProbeMixin", - "Star040RenderProbeMixin" + "Star040RenderProbeMixin", + "Water144FrameProbeMixin", + "Water144SectionProbeMixin" ] } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/SanctuaryKnowledgeClient.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/SanctuaryKnowledgeClient.java index d15f58d..53d99e5 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/SanctuaryKnowledgeClient.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/SanctuaryKnowledgeClient.java @@ -18,6 +18,7 @@ public final class SanctuaryKnowledgeClient implements ClientModInitializer { public static void registerClient() { SanctuaryResourcePack.register(); + fr.koka.sanctuary.client.shader.NaturalWater.register(); VortexCloudRenderer.register(); fr.koka.sanctuary.client.shader.PixelShadows.register(); fr.koka.sanctuary.client.shader.EdgeHighlights.register(); 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 new file mode 100644 index 0000000..1f4e200 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/NaturalWater.java @@ -0,0 +1,164 @@ +package fr.koka.sanctuary.client.shader; + +import com.google.gson.*; +import fr.koka.sanctuary.SanctuaryMod; +import java.util.*; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; +import net.fabricmc.fabric.api.resource.v1.ResourceLoader; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.block.BlockAndTintGetter; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.Identifier; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.resources.ResourceManagerReloadListener; +import net.minecraft.tags.BiomeTags; +import net.minecraft.tags.FluidTags; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.state.BlockState; + +/** Client-only water materials; no world writes, per-frame terrain scan or additional render pass. */ +public final class NaturalWater { + public static final int DEPTH=8; + public record Profile(float tint,float alpha,float fog) { + public Profile mix(Profile b,float t){if(this==b||t==0)return this;if(t==1)return b;return new Profile(tint+(b.tint-tint)*t,alpha+(b.alpha-alpha)*t,fog+(b.fog-fog)*t);} + } + public static final Profile CLEAR=new Profile(0,.55f,1),LIGHT=new Profile(.10f,.80f,.70f),POND=new Profile(.85f,1,.40f); + private static final Profile[] PROFILES={CLEAR,LIGHT,POND}; + private static volatile Map materials=Map.of(); + private static volatile float strength; + private static ClientLevel previousLevel; + private static Profile fogFrom=CLEAR,fogTarget=CLEAR; + private static long fogChanged; + private static boolean wasUnderwater; + private NaturalWater(){} + public static void register(){ + ResourceLoader.get(PackType.CLIENT_RESOURCES).registerReloadListener(Identifier.parse("sanctuary:water_materials"), + (ResourceManagerReloadListener) manager->{ + try(var reader=manager.getResourceOrThrow(Identifier.parse("sanctuary:water_materials.json")).openAsReader()){ + var root=JsonParser.parseReader(reader).getAsJsonObject();var next=new HashMap(); + for(var entry:Map.of("light",1,"pond",2).entrySet()) for(var id:root.getAsJsonArray(entry.getKey())){ + var key=Identifier.parse(id.getAsString()); + if(!BuiltInRegistries.BLOCK.containsKey(key))throw new IllegalArgumentException("Unknown water material "+key); + if(next.put(BuiltInRegistries.BLOCK.getValue(key),entry.getValue())!=null)throw new IllegalArgumentException("Duplicate water material "+key); + } + materials=Map.copyOf(next); + }catch(Exception e){SanctuaryMod.LOGGER.warn("Invalid water materials; using bundled defaults",e);materials=defaults();} + }); + ClientTickEvents.END_CLIENT_TICK.register(client->{ + float next=VanillaLight.active()&&VanillaLight.naturalWater()?VanillaLight.waterIntensity()/100f:0; + if(previousLevel!=client.level||strength!=next){previousLevel=client.level;strength=next;wasUnderwater=false; + if(client.level!=null)invalidateWaterSections(client);} + if(client.level==null||next==0){wasUnderwater=false;return;} + var camera=client.gameRenderer.mainCamera(); + if(camera.getFluidInCamera()!=net.minecraft.world.level.material.FogType.WATER){wasUnderwater=false;return;} + var pos=camera.position();var target=sample(client.level,pos.x,pos.y,pos.z); + if(!wasUnderwater){fogFrom=fogTarget=target;fogChanged=System.nanoTime();wasUnderwater=true;} + else if(!target.equals(fogTarget)){fogFrom=fogProfile();fogTarget=target;fogChanged=System.nanoTime();} + }); + } + /** Preference changes only rebuild loaded water meshes, retaining unrelated shader caches. */ + private static void invalidateWaterSections(Minecraft client){ + var level=client.level; + var center=BlockPos.containing(client.gameRenderer.mainCamera().position()); + int radius=client.options.getEffectiveRenderDistance()+1; + for(int z=(center.getZ()>>4)-radius;z<=(center.getZ()>>4)+radius;z++) + for(int x=(center.getX()>>4)-radius;x<=(center.getX()>>4)+radius;x++){ + var chunk=level.getChunkSource().getChunk(x,z,net.minecraft.world.level.chunk.status.ChunkStatus.FULL,false); + if(chunk==null)continue; + var sections=chunk.getSections(); + for(int i=0;istate.getFluidState().is(FluidTags.WATER))) + client.levelExtractor.setSectionDirty(x,level.getSectionYFromSectionIndex(i),z); + } + } + private static Map defaults(){ + var result=new HashMap(); + for(String name:new String[]{"sand","red_sand","gravel","clay"})result.put(BuiltInRegistries.BLOCK.getValue(Identifier.withDefaultNamespace(name)),1); + for(String name:new String[]{"dirt","coarse_dirt","rooted_dirt","grass_block","podzol","mycelium","mud","muddy_mangrove_roots"})result.put(BuiltInRegistries.BLOCK.getValue(Identifier.withDefaultNamespace(name)),2); + return Map.copyOf(result); + } + 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);} + private static int classify(java.util.function.Function read,BlockPos pos,Map table){ + var cursor=pos.mutable(); + for(int i=1;i<=DEPTH;i++){ + cursor.set(pos.getX(),pos.getY()-i,pos.getZ());var state=read.apply(cursor); + if(state==null||state.isAir())return 0; + if(state.isSolid()||!state.getCollisionShape(net.minecraft.world.level.EmptyBlockGetter.INSTANCE,cursor).isEmpty()){ + // Plants have no collision; a waterlogged solid remains a basin floor. + return table.getOrDefault(state.getBlock(),0); + } + if(!state.getFluidState().is(FluidTags.WATER))return 0; + } + return 0; + } + public static Profile sample(ClientLevel level,double x,double y,double z){ + var pos=BlockPos.containing(x,y,z);if(ocean(level,pos))return CLEAR; + int bx=(int)Math.floor(x-.5),bz=(int)Math.floor(z-.5),by=pos.getY(); + float dx=(float)(x-.5-bx),dz=(float)(z-.5-bz); + 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; + return PROFILES[classify(l::getBlockState,p,materials)]; + } + /** Owned by a single native section compilation, with ocean data captured before worker submission. */ + public static final class Region { + private final int ox,oy,oz; + private final net.minecraft.world.level.biome.BiomeManager biomes; + private byte[] oceanKinds; + private byte[] profiles; + private final Map table=materials; + public final float intensity=strength; + public Region(ClientLevel level,int minX,int minY,int minZ){ + ox=(minX+1)*16;oy=(minY+1)*16;oz=(minZ+1)*16; + // Copy quart holders, then keep the native seeded biome zoom on the immutable copy. + // Sampling only quart centres would misclassify the jagged edges of ocean biomes. + 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); + biomes=level.getBiomeManager().withDifferentSource((x,y,z)->copied.get(((y-qy)*6+z-qz)*6+x-qx)); + } + 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;} + return code==2; + } + private Profile profile(BlockAndTintGetter region,int x,int y,int z){ + if(xox+16||yoy+15||zoz+16)return CLEAR; + if(ocean(x,y,z))return CLEAR; + int key=((y-oy)*18+z-oz+1)*18+x-ox+1; + if(profiles==null)profiles=new byte[18*16*18]; + int code=profiles[key];if(code==0){code=1+classify(region::getBlockState,new BlockPos(x,y,z),table);profiles[key]=(byte)code;} + return PROFILES[code-1]; + } + 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; + 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); + } + } + public interface RegionAccess { Region sanctuary$waterRegion(); } + public static int color(int color,int base,Profile profile,float intensity){ + float tint=profile.tint*intensity; + int r=color>>16&255,g=color>>8&255,b=color&255; + // Restore directional face shading after tinting, including custom biome/resource-pack colours. + float shade=(r+g+b)/(float)Math.max(1,(base>>16&255)+(base>>8&255)+(base&255)); + int rr=Math.clamp(Math.round(r*(1-tint)+120*shade*tint),0,255); + int gg=Math.clamp(Math.round(g*(1-tint)+132*shade*tint),0,255); + int bb=Math.clamp(Math.round(b*(1-tint)+72*shade*tint),0,255); + int a=Math.round((color>>>24)*(1+(profile.alpha-1)*intensity)); + return a<<24|rr<<16|gg<<8|bb; + } + public static void dirty(BlockPos pos){ + if(strength==0)return; + var renderer=Minecraft.getInstance().levelExtractor; + renderer.setSectionRangeDirty((pos.getX()-1)>>4,pos.getY()>>4,(pos.getZ()-1)>>4, + (pos.getX()+1)>>4,(pos.getY()+DEPTH)>>4,(pos.getZ()+1)>>4); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/ShaderOptionsScreen.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/ShaderOptionsScreen.java index 3edda8b..0533ff9 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/ShaderOptionsScreen.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/ShaderOptionsScreen.java @@ -14,6 +14,9 @@ public final class ShaderOptionsScreen extends OptionsSubScreen { list.addBig(OptionInstance.createBoolean("sanctuary.shaders.vanilla_light",OptionInstance.cachedConstantTooltip( Component.translatable(VanillaLight.externalEngine()?"sanctuary.shaders.external":"sanctuary.shaders.help")), VanillaLight.enabled(),VanillaLight::setEnabled)); + list.addBig(OptionInstance.createBoolean("sanctuary.shaders.natural_water",OptionInstance.cachedConstantTooltip( + Component.translatable("sanctuary.shaders.natural_water_help")),VanillaLight.naturalWater(),VanillaLight::setNaturalWater)); + slider("water_intensity",VanillaLight.waterIntensity(),VanillaLight::setWaterIntensity); list.addBig(new OptionInstance("sanctuary.shaders.saturation",OptionInstance.cachedConstantTooltip( Component.translatable("sanctuary.shaders.saturation_help")),(caption,value)->Component.translatable("sanctuary.shaders.saturation_value",value), new OptionInstance.IntRange(0,200),VanillaLight.saturation(),VanillaLight::setSaturation)); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/VanillaLight.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/VanillaLight.java index 1b9c638..e62e375 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/VanillaLight.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/VanillaLight.java @@ -16,6 +16,8 @@ public final class VanillaLight { private VanillaLight() { } private static final class Preferences { boolean enabled=true; + boolean naturalWater=true; + int waterIntensity=100; int saturation=142; boolean pixelShadows=true; boolean bloom=true; @@ -58,6 +60,7 @@ public final class VanillaLight { preferences.oreIntensity=Math.clamp(preferences.oreIntensity,0,100); preferences.edgeIntensity=Math.clamp(preferences.edgeIntensity,0,100); preferences.edgeDistance=normalizeShadowDistance(preferences.edgeDistance); + preferences.waterIntensity=Math.clamp(preferences.waterIntensity,0,100); preferences.saturation=Math.clamp(preferences.saturation,0,200); preferences.shadowPixels=normalizePrecision(preferences.shadowPixels); preferences.shadowDistance=normalizeShadowDistance(preferences.shadowDistance); @@ -66,6 +69,10 @@ public final class VanillaLight { } public static boolean externalEngine(){return EXTERNAL_ENGINE;} public static boolean active(){return ProgressionClient.active()&&!EXTERNAL_ENGINE&&enabled();} + public static boolean naturalWater(){enabled();return preferences.naturalWater;} + public static int waterIntensity(){enabled();return preferences.waterIntensity;} + public static void setNaturalWater(boolean value){enabled();preferences.naturalWater=value;save();} + public static void setWaterIntensity(int value){enabled();preferences.waterIntensity=Math.clamp(value,0,100);save();} public static int saturation(){enabled();return preferences.saturation;} public static boolean edgeHighlights(){enabled();return preferences.edgeHighlights;} public static int edgeDistance(){enabled();return preferences.edgeDistance;} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/NaturalWaterFluidMixin.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/NaturalWaterFluidMixin.java new file mode 100644 index 0000000..4131fcf --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/NaturalWaterFluidMixin.java @@ -0,0 +1,48 @@ +package fr.koka.sanctuary.mixin.client; +import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; +import com.llamalad7.mixinextras.injector.wrapoperation.*; +import com.mojang.blaze3d.vertex.VertexConsumer; +import fr.koka.sanctuary.client.shader.NaturalWater; +import net.minecraft.client.color.block.BlockTintSource; +import net.minecraft.client.renderer.block.*; +import net.minecraft.core.BlockPos; +import net.minecraft.tags.FluidTags; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.material.FluidState; +import org.spongepowered.asm.mixin.*; +import org.spongepowered.asm.mixin.injection.At; +/** Native renderer instances belong to one section compiler. Context is restored even on failure. */ +@Mixin(FluidRenderer.class) +abstract class NaturalWaterFluidMixin { + @Unique private NaturalWater.Profile[] sanctuary$corners; + @Unique private NaturalWater.Region sanctuary$snapshot; + @Unique private BlockAndTintGetter sanctuary$region; + @Unique private BlockPos sanctuary$pos; + @Unique private float sanctuary$strength; + @Unique private int sanctuary$x,sanctuary$z,sanctuary$base; + @WrapMethod(method="tesselate") + private void sanctuary$water(BlockAndTintGetter region,BlockPos pos,FluidRenderer.Output output,BlockState state,FluidState fluid,Operation original){ + var previous=sanctuary$snapshot; + try{ + sanctuary$corners=null;sanctuary$snapshot=null;sanctuary$base=-1; + if(fluid.is(FluidTags.WATER)&®ion instanceof NaturalWater.RegionAccess access){ + var snapshot=access.sanctuary$waterRegion(); + if(snapshot!=null){ + sanctuary$strength=snapshot.intensity;sanctuary$x=pos.getX()&15;sanctuary$z=pos.getZ()&15; + sanctuary$snapshot=snapshot;sanctuary$region=region;sanctuary$pos=pos; + } + } + original.call(region,pos,output,state,fluid); + }finally{sanctuary$snapshot=previous;sanctuary$corners=null;} + } + @WrapOperation(method="tesselate",at=@At(value="INVOKE",target="Lnet/minecraft/client/color/block/BlockTintSource;colorInWorld(Lnet/minecraft/world/level/block/state/BlockState;Lnet/minecraft/client/renderer/block/BlockAndTintGetter;Lnet/minecraft/core/BlockPos;)I")) + private int sanctuary$base(BlockTintSource source,BlockState state,BlockAndTintGetter region,BlockPos pos,Operation original){return sanctuary$base=original.call(source,state,region,pos);} + @WrapOperation(method="vertex",at=@At(value="INVOKE",target="Lcom/mojang/blaze3d/vertex/VertexConsumer;addVertex(FFFIFFIIFFF)V")) + private void sanctuary$vertex(VertexConsumer consumer,float x,float y,float z,int color,float u,float v,int overlay,int light,float nx,float ny,float nz,Operation original){ + if(sanctuary$snapshot!=null){ + if(sanctuary$corners==null){var s=sanctuary$snapshot;var r=sanctuary$region;var p=sanctuary$pos; + sanctuary$corners=new NaturalWater.Profile[]{s.corner(r,p,0,0),s.corner(r,p,1,0),s.corner(r,p,0,1),s.corner(r,p,1,1)};} + int corner=(x-sanctuary$x>.5f?1:0)+(z-sanctuary$z>.5f?2:0);color=NaturalWater.color(color,sanctuary$base,sanctuary$corners[corner],sanctuary$strength);} + original.call(consumer,x,y,z,color,u,v,overlay,light,nx,ny,nz); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/NaturalWaterFogColorMixin.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/NaturalWaterFogColorMixin.java new file mode 100644 index 0000000..e16de1e --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/NaturalWaterFogColorMixin.java @@ -0,0 +1,24 @@ +package fr.koka.sanctuary.mixin.client; +import fr.koka.sanctuary.client.shader.NaturalWater; +import net.minecraft.client.Camera; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.fog.FogRenderer; +import net.minecraft.world.level.material.FogType; +import org.joml.Vector4f; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.*; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +@Mixin(FogRenderer.class) +abstract class NaturalWaterFogColorMixin { + @Inject(method="computeFogColor",at=@At("RETURN")) + private void sanctuary$waterColor(Camera camera,float ticks,ClientLevel level,int distance,float darken,Vector4f color,CallbackInfo ci){ + if(NaturalWater.strength()==0||camera.getFluidInCamera()!=FogType.WATER)return; + float amount=NaturalWater.strength()*NaturalWater.fogProfile().tint();if(amount==0)return; + // Apply after native water-vision normalization: normalizing olive to max=1 turns it neon yellow. + // Retain native darkness/vision brightness, including a fully black blinded environment. + float brightness=Math.max(color.x,Math.max(color.y,color.z)); + color.x+=(120/255f*brightness-color.x)*amount; + color.y+=(132/255f*brightness-color.y)*amount; + color.z+=(72/255f*brightness-color.z)*amount; + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/NaturalWaterFogMixin.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/NaturalWaterFogMixin.java new file mode 100644 index 0000000..f0a9b8d --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/NaturalWaterFogMixin.java @@ -0,0 +1,19 @@ +package fr.koka.sanctuary.mixin.client; +import fr.koka.sanctuary.client.shader.NaturalWater; +import net.minecraft.client.Camera; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.fog.FogData; +import net.minecraft.client.renderer.fog.environment.WaterFogEnvironment; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.*; +import org.spongepowered.asm.mixin.injection.callback.*; +@Mixin(WaterFogEnvironment.class) +abstract class NaturalWaterFogMixin { + @Inject(method="setupFog",at=@At("TAIL")) + private void sanctuary$fog(FogData fog,Camera camera,ClientLevel level,float distance,DeltaTracker delta,CallbackInfo ci){ + float amount=NaturalWater.strength();if(amount==0)return; + float factor=1+(NaturalWater.fogProfile().fog()-1)*amount; + fog.environmentalStart*=factor;fog.environmentalEnd*=factor;fog.skyEnd*=factor;fog.cloudEnd*=factor; + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/NaturalWaterRegionMixin.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/NaturalWaterRegionMixin.java new file mode 100644 index 0000000..8019c62 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/NaturalWaterRegionMixin.java @@ -0,0 +1,23 @@ +package fr.koka.sanctuary.mixin.client; +import fr.koka.sanctuary.client.shader.NaturalWater; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.chunk.RenderSectionRegion; +import org.spongepowered.asm.mixin.*; +import org.spongepowered.asm.mixin.injection.*; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +@Mixin(RenderSectionRegion.class) +abstract class NaturalWaterRegionMixin implements NaturalWater.RegionAccess { + @Shadow @Final private ClientLevel level; + @Shadow @Final private net.minecraft.client.renderer.chunk.SectionCopy[] sections; + @Shadow @Final private int minSectionX,minSectionY,minSectionZ; + @Unique private NaturalWater.Region sanctuary$water; + @Inject(method="",at=@At("RETURN")) + private void sanctuary$snapshot(CallbackInfo ci){ + if(NaturalWater.strength()==0)return; + // Native 3×3×3 region, centre index 13. Palette rejection avoids 216 biome reads for dry terrain. + var states=((NaturalWaterSectionCopyAccess)sections[13]).sanctuary$states(); + if(states!=null&&states.maybeHas(state->state.getFluidState().is(net.minecraft.tags.FluidTags.WATER))) + sanctuary$water=new NaturalWater.Region(level,minSectionX,minSectionY,minSectionZ); + } + public NaturalWater.Region sanctuary$waterRegion(){return sanctuary$water;} +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/NaturalWaterSectionCopyAccess.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/NaturalWaterSectionCopyAccess.java new file mode 100644 index 0000000..36e1e5a --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/NaturalWaterSectionCopyAccess.java @@ -0,0 +1,10 @@ +package fr.koka.sanctuary.mixin.client; +import net.minecraft.client.renderer.chunk.SectionCopy; +import net.minecraft.world.level.chunk.PalettedContainer; +import net.minecraft.world.level.block.state.BlockState; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; +@Mixin(SectionCopy.class) +interface NaturalWaterSectionCopyAccess { + @Accessor("section") PalettedContainer sanctuary$states(); +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/PixelShadowDirtyMixin.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/PixelShadowDirtyMixin.java index 5aa4928..e3c51f5 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/PixelShadowDirtyMixin.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/PixelShadowDirtyMixin.java @@ -17,6 +17,7 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; abstract class PixelShadowDirtyMixin { @Inject(method="blockChanged", at=@At("HEAD")) private void sanctuary$dirty(BlockPos pos, int flags, CallbackInfo ci) { + fr.koka.sanctuary.client.shader.NaturalWater.dirty(pos); PixelShadows.dirtyBlock(pos); ProceduralPbr.dirtyBlock(pos); Bloom.dirtyBlock(pos); ColoredLights.dirtyBlock(pos); } @Inject(method="allChanged", at=@At("HEAD")) diff --git a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json index 2117483..fef2813 100644 --- a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json +++ b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json @@ -2319,5 +2319,10 @@ "sanctuary.shaders.colored_light_distance_value": "Colored lights: %s blocks", "sanctuary.shaders.colored_light_distance_help": "View distance: 8 to 64 blocks. Longer distances use more memory and computation. Smooth range fade; each source keeps its native reach.", "sanctuary.shaders.warm_torches": "Warm torches", - "sanctuary.shaders.warm_torches_help": "Off: ordinary torches keep the native color temperature. On: orange tint. Other sources retain their own colors." + "sanctuary.shaders.warm_torches_help": "Off: ordinary torches keep the native color temperature. On: orange tint. Other sources retain their own colors.", + "sanctuary.shaders.natural_water": "Natural water", + "sanctuary.shaders.natural_water_help": "Biome colour, clarity from the bed. Clear oceans. Also changes underwater visibility.", + "sanctuary.shaders.water_intensity": "Water intensity", + "sanctuary.shaders.water_intensity_help": "At 0%, keeps native rendering. At 100%, applies all three profiles.", + "sanctuary.shaders.water_intensity_value": "Water intensity: %s%%" } diff --git a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json index beeacf5..2bd7b26 100644 --- a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json +++ b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json @@ -2319,5 +2319,10 @@ "sanctuary.shaders.colored_light_distance_value": "Lumières colorées : %s blocs", "sanctuary.shaders.colored_light_distance_help": "Distance d'affichage : 8 à 64 blocs. Les grandes distances utilisent plus de mémoire et de calcul. Fondu aux limites ; la portée de chaque source reste native.", "sanctuary.shaders.warm_torches": "Torches chaudes", - "sanctuary.shaders.warm_torches_help": "Désactivé : les torches ordinaires conservent la température de couleur native. Activé : teinte orangée. Les autres sources gardent leurs propres couleurs." + "sanctuary.shaders.warm_torches_help": "Désactivé : les torches ordinaires conservent la température de couleur native. Activé : teinte orangée. Les autres sources gardent leurs propres couleurs.", + "sanctuary.shaders.natural_water": "Eau naturelle", + "sanctuary.shaders.natural_water_help": "Couleur du biome, clarté selon le fond. Océans clairs. Modifie aussi la visibilité sous l’eau.", + "sanctuary.shaders.water_intensity": "Intensité de l’eau", + "sanctuary.shaders.water_intensity_help": "À 0 %, conserve le rendu natif. À 100 %, applique les trois profils.", + "sanctuary.shaders.water_intensity_value": "Intensité de l’eau: %s%%" } diff --git a/mods/sanctuary/src/main/resources/assets/sanctuary/water_materials.json b/mods/sanctuary/src/main/resources/assets/sanctuary/water_materials.json new file mode 100644 index 0000000..9c114ff --- /dev/null +++ b/mods/sanctuary/src/main/resources/assets/sanctuary/water_materials.json @@ -0,0 +1,4 @@ +{ + "light": ["minecraft:sand", "minecraft:red_sand", "minecraft:gravel", "minecraft:clay"], + "pond": ["minecraft:dirt", "minecraft:coarse_dirt", "minecraft:rooted_dirt", "minecraft:grass_block", "minecraft:podzol", "minecraft:mycelium", "minecraft:mud", "minecraft:muddy_mangrove_roots"] +} diff --git a/mods/sanctuary/src/main/resources/sanctuary.mixins.json b/mods/sanctuary/src/main/resources/sanctuary.mixins.json index 4b8b87f..c2017b0 100644 --- a/mods/sanctuary/src/main/resources/sanctuary.mixins.json +++ b/mods/sanctuary/src/main/resources/sanctuary.mixins.json @@ -278,7 +278,12 @@ "client.PixelShadowRendererMixin", "client.PixelShadowLevelMixin", "client.PixelShadowDirtyMixin", - "client.PreferredGraphicsApiMixin" + "client.PreferredGraphicsApiMixin", + "client.NaturalWaterRegionMixin", + "client.NaturalWaterFluidMixin", + "client.NaturalWaterFogMixin", + "client.NaturalWaterFogColorMixin", + "client.NaturalWaterSectionCopyAccess" ], "injectors": { "defaultRequire": 1 diff --git a/packwiz/pack.toml b/packwiz/pack.toml index 90775fe..03a0d8c 100644 --- a/packwiz/pack.toml +++ b/packwiz/pack.toml @@ -1,6 +1,6 @@ name = "Sanctuary" author = "KOKA99CAB" -version = "beta.143" +version = "beta.144" pack-format = "packwiz:1.1.0" [index] diff --git a/scripts/check_water_performance.py b/scripts/check_water_performance.py new file mode 100644 index 0000000..8a3f611 --- /dev/null +++ b/scripts/check_water_performance.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Check the eight alternating water benchmark passages per scene. + +Usage: python3 scripts/check_water_performance.py build/water144-vulkan.log +Writes the detailed receipt beside the log, and rejects incomplete/over-budget runs. +The caller must also verify that no other graphics test ran during measurement. +""" +import json,re,sys +from pathlib import Path +log_path=Path(sys.argv[1]) +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)) +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) + 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} + value["passed"]=value["medianIncreasePercent"]<=5 and value["p95IncreaseMs"]<=2 + result["scenes"][scene]=value +log_path.with_name(log_path.stem+"-performance.json").write_text(json.dumps(result,indent=2)+"\n") +print(json.dumps(result["scenes"],indent=2)) +assert all(v["passed"] for v in result["scenes"].values()), "Performance acceptance not met" diff --git a/shaders-pack/README.md b/shaders-pack/README.md index c497e28..f813042 100644 --- a/shaders-pack/README.md +++ b/shaders-pack/README.md @@ -8,3 +8,6 @@ ajoute les ombres portées pixélisées optionnelles, configurables depuis Ce dossier reste réservé aux futurs packs externes. Leur ajout au manifeste packwiz nécessitera version, URL, hash, licence et compatibilité vérifiée pour la version exacte du jeu et du moteur Iris. + +La beta.144 ajoute l’[eau naturelle](../docs/natural-water-beta144.md) dans +le rendu natif : profils selon le fond, océans clairs et brume sous-marine.