Compare commits

..
Author SHA1 Message Date
koka cf9a670f44 Prepare beta.146 reflections and water blending
Build Sanctuary / build (push) Waiting to run
2026-09-18 19:40:25 +02:00
koka ed2d088272 Validate and package experimental material reflections as beta.145
Build Sanctuary / build (push) Waiting to run
2026-09-18 18:04:21 +02:00
koka 0cfd51eb4f Integrate frozen beta.144 natural water sources for combined SSR validation 2026-09-18 14:41:53 +02:00
koka 38f4d6c9a9 Implement native texel reflections and transparent PBR surfaces 2026-09-18 14:39:26 +02:00
koka 580b2414da Document beta.143 publication and Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-18 12:44:49 +02:00
koka 97ff2b54cb Slow seasonal snow pacing in beta.143
Build Sanctuary / build (push) Canceled after 0s
2026-09-18 12:41:27 +02:00
61 changed files with 2296 additions and 96 deletions
+41 -8
View File
@@ -1,5 +1,22 @@
# Sanctuary
## beta.146 — Reflets et transitions de l’eau (pack local)
Correction du masquage des personnages en F5 et sélection stricte des surfaces PBR.
PBR à 80 % et SSR à 20 % par défaut, distance commune PBR/SSR
(16/32/64/128 blocs), reflet solaire large et blanc avec léger bloom des matières.
Eau naturelle selon le type de monde et mélange 1/3/5/9/17 blocs.
[Contrat et vérifications](docs/reflections-water-beta146.md).
## beta.145 — PBR de l’eau et reflets pixélisés (livraison locale)
L’eau et le verre rejoignent le PBR natif. L’option expérimentale de reflets
alimente les texels avec le paysage visible selon leurs normales.
[Contrat, vérifications et limites](docs/pbr-ssr.md).
Cette branche intègre un instantané des sources d’eau beta.144 ; elle ne
publie pas le canal et ne synchronise pas Prism.
Sanctuary est une extension gratuite de Minecraft qui transforme le jeu en un
monde flottant d'exploration, de production et de progression collective.
Les joueurs commencent ensemble sur Sanctuary Island et construisent les
@@ -20,12 +37,18 @@ 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.142** utilise Vulkan comme moteur graphique par défaut,
stabilise les rafraîchissements du relief PBR et règle son intensité initiale
à 80 %. Les lectures de textures et calculs de reflets sont sécurisés contre
les résultats indéfinis. Le [ticket](docs/pbr-refresh-beta142.md) distingue les
corrections vérifiées du bruit signalé en mouvement sur le pilote de l'utilisateur.
La beta.142 est publiée et synchronisée dans l’instance Sanctuary Beta ;
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
restent inchangées : neige construite, biomes froids, hiver, profils neigeux
et météo Vanilla. Le [ticket](docs/realtime-snow-melt-beta143.md) documente le
contrat et les vérifications.
La beta.143 est publiée et synchronisée dans l’instance Sanctuary Beta ;
les 923 fichiers personnels suivis sont conservés.
La [livraison précédente](docs/clay-workshop-integration-beta110.md) réunit déjà
l’atelier, les œufs et la neige saisonnière dans l’instance **Sanctuary Beta**.
@@ -37,6 +60,16 @@ La [carte native beta.005](docs/atlas-beta005.md) prend le relais après
l’étude de Xaero et des alternatives libres.
## beta.143 — Neige saisonnière en temps réel
Accumulation ralentie par un facteur 12 et fonte saisonnière ralentie par le
facteur naturel 72, tout en conservant le sampler natif des colonnes de
précipitation. Deux blocs de neige fondent en environ 2,7 jours réels en été,
5,4 au printemps et 11 en automne.
[Contrat et vérifications](docs/realtime-snow-melt-beta143.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.143/Sanctuary-beta.143.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.143/Sanctuary-Test-beta.143.mrpack).
## beta.142 — Vulkan par défaut et stabilité PBR
Moteur natif Vulkan prioritaire, relief conservé pendant les reconstructions,
@@ -2303,7 +2336,7 @@ Depuis beta.090, la cible est **Minecraft 26.3 finale**, publiée le 15 septembr
| Fabric API | 0.160.5+26.3 |
| Fabric Loom | 1.17.20 |
| Gradle Wrapper | 9.5.1, distribution vérifiée par SHA-256 |
| Sanctuary / pack | beta.122 |
| Sanctuary / pack | beta.146 |
Java 25 et Python 3.11 ou plus récent sont nécessaires. Le script pack utilise
uniquement la bibliothèque standard et repère aussi une installation Python
@@ -2332,7 +2365,7 @@ décrits dans [Validation](docs/testing.md).
Résultats :
- `mods/sanctuary/build/libs/sanctuary-beta.122.jar` : mod à installer avec
- `mods/sanctuary/build/libs/sanctuary-beta.146.jar` : mod à installer avec
Fabric API sur la version Minecraft indiquée.
- `build/packwiz/` : pack de développement complet, avec le mod construit et
l'index vérifié. Voir [Installation packwiz](packwiz/README.md).
+121
View File
@@ -0,0 +1,121 @@
# 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é.
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/`.
+21
View File
@@ -14,6 +14,27 @@ 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,
avec le sampler natif de précipitation conservé. Protections inchangées pour
la neige construite, les biomes froids, l'hiver, les profils neigeux et la
météo Vanilla. [Contrat et vérifications](realtime-snow-melt-beta143.md).
[Release beta.143](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.143)
publiée ; canal `bafff5e9fcdd95c5dd184953a34ada805e455797`. Deux synchronisations
isolées puis deux dans l'instance Sanctuary Beta réussies. Les 923 fichiers
personnels suivis gardent leurs hashes. Aucun monde personnel ouvert ;
sauvegarde préalable `sanctuary-backups/before-beta.143/`. Archives vérifiées
dans `sanctuary-beta/build/`.
## beta.142 — Vulkan par défaut et stabilité PBR
Vulkan natif prioritaire, rafraîchissements PBR stables et intensité initiale
+221
View File
@@ -0,0 +1,221 @@
# PBR-SSR — eau, verre et reflets pixélisés
Livraison locale **beta.145**, branche `codex/pbr-ssr`. Socle PBR beta.142 /
suivi beta.143 et copie figée des sources du chantier d’eau beta.144. Minecraft 26.3, API graphique native.
## Contrat
Les reflets colorent les texels de matière sur une grille 16 × 16 liée aux UV,
avec les normales procédurales existantes. Eau et verre sont diélectriques.
L’eau reprend les faces du tessellateur natif (niveaux, pentes, écoulements,
waterlogging), sans modification de géométrie ni simulation de vagues.
Le verre réfléchit aussi sur les zones sans pigment de sa texture. Les pixels
pigmentés conservent leur relief même avec un alpha faible ; l’alpha nul
utilise la normale géométrique.
Les images des normales d’eau sont préparées au chargement du matériau ; leur
séquence suit l’horloge effective des animations de l’atlas, avec interpolation.
Seules les petites tuiles de normales changent sur le GPU, pas le maillage.
Les normales des textures haute résolution sont rééchantillonnées à 16 × 16.
Au-delà de 1 024 images par sprite, un avertissement signale le repli vers
une normale fixe pour borner la mémoire.
L’option FR/EN « Reflets du paysage — expérimental » est OFF initialement,
intensité 50 %, bornée entre 0 et 100. Elle exige le PBR actif. L’eau naturelle
reste indépendante pour ses couleurs, sa transparence et sa brume.
## Pipeline et limites
Couleur et profondeur du paysage sont copiées après les opaques et avant les
transparences, dans les deux chemins natifs. Le chemin classique ferme la passe
opaque avant la copie, puis reprend la transparence native dans une nouvelle
passe. Aucune copie d’attachement Vulkan encore ouvert.
Une profondeur propre sélectionne le récepteur PBR le plus proche devant le
paysage opaque. Une vitre devant l’eau reçoit seule le SSR ; les couches
suivantes conservent leur rendu natif. La profondeur du fond n’est pas utilisée
comme profondeur de surface de l’eau.
8 à 32 pas déterministes adaptés à la longueur écran, puis quatre raffinements maximum, récepteurs jusqu’à
32 blocs et rayons jusqu’à 128 blocs. Pas de bruit aléatoire, historique,
réflexion récursive, réfraction nouvelle, lecture GPU vers CPU ou ajout de
lumière de gameplay. Les objets hors écran ou masqués ne sont pas retrouvés. Les détails fins entre
deux pas peuvent être manqués ; une interpolation finale affine le point
d’intersection sans augmenter le nombre de recherches.
Un échec revient progressivement au rendu existant ; les transparences et les
effets Sanctuary postérieurs à la capture ne figurent pas dans le reflet.
Les reflets sont composés après le SSGI, avant contours, bloom et rayons.
La main et le HUD restent en dehors du traitement. Le PBR conserve son
périmètre Overworld hors immersion et s’efface avec les moteurs externes.
Budget de maillages : 24 Mio de solides, 8 Mio supplémentaires de transparences.
Deux profondeurs plein écran servent aux surfaces PBR ; la couleur de paysage
SSR est allouée seulement à ON/intensité positive. La copie de composition PBR
existante est réutilisée. Aucun buffer de normales plein écran.
La grille visuelle ne signifie pas qu’un seul fragment GPU est exécuté par texel.
## Intégration
Le commit `38f4d6c` apporte le SSR et les récepteurs transparents. Le commit
`0cfd51e` importe une copie contrôlée des sources beta.144 depuis le chantier
`codex/water-beta144`, fondé sur `580b241`. Les empreintes des fichiers ont été
comparées avant et après la copie ; le reçu local est dans
`build/water144-snapshot/manifest.json`. Cette intégration ne prétend pas que
beta.144 a été publiée ou que son benchmark isolé est terminé. Le code de
production de cet instantané est identique à celui du commit
`7eef945` ensuite créé sur la branche d’eau. Les essais ci-dessous portent
sur les sources réunies dans cette branche.
Points d’entrée : `PbrMaterials` et `PbrAnimation` pour les normales animées,
`PbrFluidMesh` pour le tessellateur natif, `PbrTerrain` pour les deux budgets,
`ReflectionSceneMixin` pour la capture opaque, `PixelReflections` et
`pixel_reflections.fsh` pour la sélection et le rayon. Les horloges de sprites
sont référencées faiblement des deux côtés afin de ne pas retenir les anciens
atlas après rechargement.
## Vérifications
Machine : Apple M1, Minecraft 26.3, Java 25, macOS. Mondes plats de test neufs,
graine 122 ; aucune sauvegarde personnelle utilisée. Les captures conservent
le préfixe historique `sanctuary-beta142-shader` du banc de test.
- Client OpenGL 4.1 Metal 90.5, transparence classique : suite SSR réussie avec les sources d’eau
intégrées (`build/ssr145-opengl-final.log`).
- Client Vulkan/MoltenVK : suite SSR avec transparence améliorée réussie
(`build/ssr145-vulkan-final.log`).
- Régression du PBR historique et nouvelles assertions de profils, normales
transparentes, persistance, bornes et traductions SSR : réussies
(`build/ssr145-pbr-regression-retry.log`). Une première tentative interrompue
s’était arrêtée sur un timeout de sémaphore Vulkan ; elle reste un échec
consigné dans `build/ssr145-pbr-regression.log`.
- Suite fonctionnelle eau beta.144 : réussie, y compris profils, fond modifié,
brume, océan, rechargement, préférences, cascades et pack de matériaux
(`build/ssr145-water-functional.log`). Son long benchmark autonome a été
explicitement sauté ; le coût SSR est mesuré séparément ci-dessous.
- Le relief rouge puis bleu produit deux contributions de couleur distinctes
dans le bassin ; caché par un mur opaque, ce changement ne traverse pas le mur.
- Eau animée sans nouvelle analyse de texture, verre transparent devant l’eau,
métal, déplacement minime de caméra, rotation, OFF/zéro, immersion,
rechargement de ressources et redimensionnement sont exercés par assertions.
L’horloge d’eau reste active après rechargement ; les profils sable et étang
restent corrects avec PBR et SSR simultanément actifs.
- Des captures couvrent également les effets combinés, la lune, les angles
rasants, la translation de caméra, la sortie du paysage hors écran, la pierre
rugueuse OFF/ON, des vitres colorées superposées, un écoulement, une cascade
et une dalle gorgée d’eau. Elles ne remplacent pas une inspection
artistique de tous les angles et de tous les packs.
Captures locales OpenGL : [OFF](../build/ssr-evidence/beta145-opengl-final/0000_sanctuary-beta142-shader-ssr-water-off.png),
[ON](../build/ssr-evidence/beta145-opengl-final/0001_sanctuary-beta142-shader-ssr-water-on.png).
Le dossier `build/ssr-evidence/beta145-opengl-final/` contient les autres captures.
Les mêmes cas Vulkan sont conservés dans
`build/ssr-evidence/beta145-vulkan-final/`. Les comparaisons eau, métal et
verre empilé ont été inspectées visuellement. Ces pièces sont ignorées par Git.
## Coût mesuré
1920 × 1080, PBR à 80 %, SSR à 100 % pour accentuer le résultat, VSync OFF,
limite de FPS et réduction AFK désactivées. Chaque scène chauffe 30 ticks par
état avant 80 ticks de mesure, soit 324 à 483 images pour OpenGL et 317 à 477 pour Vulkan. Ordre OFF puis
ON ; il s’agit d’un diagnostic local court, pas d’une campagne statistique.
Le temps de frame mesure `Minecraft.renderFrame`, présentation comprise.
| OpenGL | Médiane OFF / ON (ms) | p95 OFF / ON (ms) | Écart médian |
| --- | --- | --- | --- |
| Petit étang | 7,57 / 9,42 | 10,35 / 12,50 | +1,85 ms |
| Eau dominante | 7,76 / 11,67 | 10,67 / 13,96 | +3,91 ms |
| Construction vitrée | 7,85 / 10,12 | 10,67 / 12,65 | +2,27 ms |
| Vulkan | Médiane OFF / ON (ms) | p95 OFF / ON (ms) | Écart médian |
| --- | --- | --- | --- |
| Petit étang | 7,97 / 10,36 | 8,97 / 11,49 | +2,40 ms |
| Eau dominante | 10,10 / 12,23 | 10,97 / 12,97 | +2,13 ms |
| Construction vitrée | 8,52 / 11,25 | 9,49 / 12,20 | +2,72 ms |
La mesure GPU Vulkan autour de `PixelReflections.render` donne, à ON,
**2,07 / 2,18 ms** (médiane / p95) pour l’étang, **2,56 / 2,63 ms** pour
l’eau dominante et **2,09 / 2,21 ms** pour le verre. À OFF, la sonde mesure
0,03 ms environ. Ce périmètre couvre la copie de composition et le dessin
SSR ; il exclut la capture opaque antérieure et la profondeur partagée PBR.
La sonde utilise les valeurs rendues par la requête native, pas une mesure
GPU du jeu complet.
La requête GPU native ne fournit pas de mesure exploitable sur ce pilote
OpenGL ; ce champ est indisponible, pas nul. Les premiers essais contaminés
par la réduction AFK à 30 FPS sont exclus des résultats retenus.
**L’objectif de surcoût médian inférieur à 2 ms n’est pas respecté dans l’ensemble des
scènes.** L’option reste expérimentale et désactivée par défaut.
À 1080p : profondeurs partagées par le PBR = 15,82 Mio ; copie couleur SSR
supplémentaire = 7,91 Mio, libérée à OFF/zéro. Total de ces cibles à ON :
23,73 Mio. Les maillages observés occupent environ 0,51–0,55 Mio de solides et
1,09–1,21 Mio de transparences, en dessous des plafonds 24 / 8 Mio vérifiés
par assertion. Ces compteurs n’incluent ni la mémoire du moteur, ni la copie
PBR existante, ni les atlas de matériaux de 6,25 Mio chacun ; ce ne sont pas
des mesures de mémoire totale du processus.
## Construction et contrôles généraux
`./gradlew check build` a été exécuté jusqu’au bout : **229 GameTests serveur
réussis sur 252, 23 échecs**, en 8 min 45 s pour la commande complète.
Les échecs concernent les tombes/familiers, le catalogue, deux invariants de
génération, les placements de blocs/progression/construction et les menus
natifs. Les tests concernés et le code de gameplay serveur ne sont pas
modifiés par ce lot. Aucun contre-test de toute la branche de départ n’est
revendiqué. Le journal complet est `build/ssr145-check-build.log` ; le relevé
exhaustif est `build/ssr145-server-failures.json`.
L’assemblage est repris avec `./gradlew check build assemblePack assembleTestPack
-x :sanctuary:runGameTest`, en excluant uniquement cette tâche déjà exécutée.
Cette commande réussit en 2 min 56 s (126 tâches) ; vérification du pack,
compilation, contrôles hors GameTests serveur et assemblages passent. Les
exports MRpack sont produits depuis leurs dossiers packwiz de staging.
L’intégrité ZIP, les métadonnées, les traductions et mixins SSR ainsi que
l’identité du JAR embarqué dans les deux packs sont contrôlées.
Cette exclusion ne transforme pas le contrôle complet en succès.
## Reproduire les essais client
Base de chaque commande :
```sh
./gradlew :sanctuary:runClientGameTest \
-PsanctuaryClientTests=true -PsanctuaryQuickTests=true \
-PsanctuaryPbr134ClientTests=true -PsanctuaryClientNoVsync=true
```
- SSR Vulkan : ajouter `-PsanctuarySsrClientTests=true
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientImprovedTransparency=true`.
- SSR OpenGL : ajouter `-PsanctuarySsrClientTests=true
-PsanctuaryClientGraphicsBackend=opengl -PsanctuaryClientImprovedTransparency=false`.
- Régression PBR : ajouter uniquement `-PsanctuaryClientGraphicsBackend=vulkan`.
- Profils et rendu de l’eau : ajouter `-PsanctuaryWater144ClientTests=true
-PsanctuaryWater144FunctionalOnly=true -PsanctuaryClientGraphicsBackend=vulkan`.
Le mode fonctionnel saute uniquement le long benchmark autonome beta.144.
Les sondes de temps de frame/GPU et lectures d’images appartiennent au mod
de test ; elles ne sont pas embarquées dans le JAR de production.
## Artefacts locaux
Les archives finales sont rafraîchies depuis le JAR testé, puis revérifiées
(`build/ssr145-artifacts.json`). Aucun tag ni canal n’est publié.
| Artefact | Octets | SHA-256 |
| --- | ---: | --- |
| [sanctuary-beta.145.jar](../mods/sanctuary/build/libs/sanctuary-beta.145.jar) | 11369411 | `55341e2f5c8dcc352190fe96d15e86cd1ee6bb4ea2908441b70491d48f86f608` |
| [Sanctuary-beta.145.mrpack](../build/Sanctuary-beta.145.mrpack) | 10508045 | `8bebe6227cbfe629c714a7c7547640ab5ccfbefaaf211484a93c042cfb42b5c5` |
| [Sanctuary-Test-beta.145.mrpack](../build/Sanctuary-Test-beta.145.mrpack) | 10526966 | `54402ff8414ed1ae0d12b3b97af70c547c44dde2f3aa183712ae3769f99efbe7` |
| [Sanctuary-Template-beta.145.zip](../build/Sanctuary-Template-beta.145.zip) | 4358316 | `226d92362a5ce922b8757b36f30c312081aca5cf73a299b82a221a12f384a95b` |
## Limites de validation
Le banc utilise un relief construit reproductible, pas une montagne générée
représentative de tous les biomes. La validation artistique sur terrain généré,
les textures personnalisées avec ordre/durées/interpolation atypiques et la
stabilité sur un long trajet restent à compléter. Les surfaces peuvent perdre les petits détails entre
les pas SSR et les zones hors écran ne sont jamais reconstruites. Windows,
autres GPU et moteurs graphiques externes ne sont pas validés ici.
Aucune publication de canal ni synchronisation Prism dans cette livraison.
+68
View File
@@ -0,0 +1,68 @@
# SNOW-143 — Neige saisonnière en temps réel
Branche `codex/realtime-snow-melt-beta143`, socle beta.142. Minecraft 26.3 /
Java 25. Livraison beta.143.
## Comportement
L'accumulation et la fonte saisonnière conservent le sampler natif de
précipitation `ServerLevel.tickPrecipitation`. Sanctuary ne scanne donc toujours
pas les chunks et ne force aucun chargement de terrain. Le changement porte
seulement sur la probabilité des mutations de neige.
Pendant une chute de neige, Sanctuary ajoute désormais une couche une fois sur
12 échantillons natifs en moyenne. La neige reste visible pendant une session,
mais les colonnes ne montent plus instantanément. À `randomTickSpeed = 3`, une
couche apparaît en moyenne après environ 40 minutes de neige continue.
Pour la fonte, chaque tirage saisonnier est multiplié par `72`, le rapport
entre une journée réelle de 24 heures et une journée Minecraft de 20 minutes.
Une couche fond désormais en moyenne en 4 h 05 en été,
8 h 10 au printemps et 16 h 25 en automne. Avec le plafond actuel de deux blocs,
une colonne météo suivie met environ 2,7 jours réels à disparaître en été,
5,4 jours au printemps et 11 jours en automne. L'hiver ne fond toujours pas.
Les règles de protection restent celles de beta.109 : seule la neige déposée
par la météo Sanctuary depuis l'ajout du suivi `sanctuary:natural_snow_v1`
peut fondre. La neige construite, l'ancienne neige non suivie, les couches
manuelles, les biomes froids, les profils neigeux et la météo Vanilla restent
préservés. Aucun format de sauvegarde ne change.
## Vérifications
Vérifié le 18 septembre 2026 sur macOS, Java 25.
- `./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true
-PsanctuaryMelt109ClientTests=true -PsanctuaryClientNoVsync=true
-PsanctuaryQuickTests=true` : réussi en 57 s, avec le marqueur
`MELT109_NATIVE_PASS`. Le test couvre l'accumulation beta.108 ralentie,
la fonte couche par couche, la provenance persistante, les saisons, la
gamerule, la météo Vanilla, les profils neigeux, les biomes froids et le
rechargement de monde.
- `./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest` :
réussi en 2 min 31 s, 126 tâches. Le GameTest serveur dédié reste exclu
conformément au refus antérieur de son EULA ; les contrôles natifs utilisent
le serveur intégré.
- `python3 scripts/pack.py check` : versions, icône et index packwiz vérifiés.
- Exports MRpack produits avec `packwiz modrinth export`, intégrité ZIP vérifiée.
Les deux MRpack embarquent exactement le JAR Sanctuary construit.
| Artefact | Taille | SHA-256 |
| --- | ---: | --- |
| `sanctuary-beta.143.jar` | 11 323 665 octets | `fd4b663ac2b2a106624c42b09997db229cc14d6c39d44ae83079bc6aa9d39a08` |
| `Sanctuary-beta.143.mrpack` | 10 464 931 octets | `e55caaa41c5a3a2666ff5415846e18f44d504ac34b698f3d974091cfb52364c5` |
| `Sanctuary-Test-beta.143.mrpack` | 10 483 858 octets | `b14bca04c4a48771209d0f7e234ff9ef6b800eb236fb969a6f0dfcee5620dafc` |
| `Sanctuary-Template-beta.143.zip` | 4 354 153 octets | `071fd8da67d1d0224b89889a5fcbc4cd269673dddb7a52f89aa78802f154605a` |
## Publication
[Release beta.143](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.143)
publiée depuis le commit `97ff2b54cb63afe8fdd88bfa8f2ebb25dbe3b86d`.
Le canal packwiz stable pointe sur
`bafff5e9fcdd95c5dd184953a34ada805e455797`.
Deux synchronisations isolées, puis deux synchronisations dans l'instance
Sanctuary Beta ont réussi. Les 923 fichiers personnels suivis gardent leurs
hashes ; aucun monde personnel n'a été ouvert. Sauvegarde préalable :
`sanctuary-backups/before-beta.143/`.
+112
View File
@@ -0,0 +1,112 @@
# RENDER-146 — Reflets, portée commune et transitions de l’eau
Branche `codex/reflections-water-beta146`, socle beta.145 `ed2d088`.
Minecraft 26.3, Java 25 et dépendances inchangés. Préparation locale uniquement.
## Contrat
- Les passes PBR et SSR vérifient la profondeur finale du rendu natif pour
masquer les personnages, notamment en F5. La profondeur du paysage capturée
avant les transparences reste celle utilisée pour rechercher le reflet.
- Le PBR utilise le test matériel EQUAL contre la profondeur du récepteur
sélectionné. L’ancienne tolérance de 3,5 cm pouvait accepter des faces
concurrentes près des coins ; elle est supprimée. La comparaison avec la
profondeur native conserve seulement une marge de 5 mm pour les différences
d’ordre de calcul des matrices, sans relâcher la sélection stricte du récepteur.
La position du vertex est déclarée invariante entre les passes.
- PBR à 80 % et SSR à 20 % par défaut, pour toutes les matières, eau comprise.
Deux intensités d’effets distinctes, sans réglage séparé solide/liquide.
Les préférences explicites déjà enregistrées restent conservées.
- Distance PBR/SSR commune : 16, 32 (défaut), 64, 128 blocs. Maillages,
sélection des récepteurs et fondu final emploient ce réglage. Le rayon SSR
conserve sa recherche maximale de 128 blocs dans le paysage visible.
Budgets de maillages inchangés : solides 24 Mio, transparences 8 Mio.
Une forte densité à grande portée peut donc limiter les surfaces disponibles.
- Le reflet du soleil adopte un lobe plus large sur l’eau, avec une irradiance
renforcée : grande empreinte blanche vue du dessus et traînée étendue aux
angles rasants. Les lampes conservent leur lobe local.
Les éclats spéculaires ne sont plus plafonnés au gris moyen. Leur compression
conserve une valeur finie pouvant approcher le blanc. Une contribution douce
des seules passes PBR/SSR alimente le bloom existant, sans faire briller tous
les blocs blancs. Le réglage Bloom et son intensité gardent autorité.
- Eau naturelle OFF par défaut dans une dimension ordinaire, ON pour une
dimension dont le générateur est Sanctuary / ExpansionChunkGenerator.
Le serveur transmet ce contexte à la connexion, au changement de dimension
et à la réapparition, y compris en multijoueur. Le défaut automatique nécessite
ce contexte envoyé par un serveur beta.146 ; sans lui, le client utilise le
défaut ordinaire OFF, toujours réglable. Aucune donnée de monde modifiée.
Les choix ON/OFF sont mémorisés séparément pour ces deux catégories.
Un ancien OFF explicite est conservé dans les deux ; un ancien ON global
adopte les nouveaux défauts par contexte.
- Mélange des profils sur 1×1, 3×3, 5×5, 9×9 (défaut) ou 17×17 blocs, puis
interpolation aux sommets. Océans toujours clairs. La texture demeure native.
Surface et immersion utilisent la même étendue. Les changements du fond
invalident toute la largeur dépendante ; changer le réglage reconstruit les
sections chargées contenant de l’eau.
Le filtre de surface est calculé dans la copie de région native, avec sommes
préfixées par hauteur pour borner le coût des sommets. Aucun accès au monde
vivant depuis le worker, chargement de chunk ou modification de sauvegarde.
Les textes des nouvelles options sont fournis en français et en anglais.
## Vérifications
Machine : Apple M1, macOS, Java 25, Minecraft 26.3. Mondes plats jetables,
graine 122 ; aucune sauvegarde personnelle utilisée.
- Suite SSR/PBR Vulkan avec transparence améliorée : réussie
(`build/beta146-final-settings-vulkan.log`). F5 avec personnage réel en créatif,
portée 16/32/64/128, couleur du paysage, eau/verre/métal, OFF/zéro,
immersion, rechargement de ressources et redimensionnement.
- Suite SSR/PBR OpenGL finale réussie également
(`build/beta146-final-settings-opengl.log`), avec 22 450 pixels quasi blancs
dans la scène solaire et 99 / 518 400 pixels modifiés dans le contrôle
des intersections. Captures dans `build/evidence/final-settings-opengl/`.
- F5 : les 1 900 pixels du torse contrôlés sont strictement identiques entre
SSR OFF et ON, tandis que le reflet reste présent autour du personnage.
- Soleil : 22 111 pixels quasi blancs sur 854 × 480 à PBR 80 % / SSR 20 %,
sans bloom, sur la scène de contrôle Vulkan. Les captures vues du dessus et
à soleil bas montrent une empreinte large et une traînée allongée.
Bloom OFF libère sa cible.
- Mur, sol et escaliers en contact : 86 pixels sur 518 400 changent de plus
de 12 niveaux cumulés lors d’une rotation de 0,00002 degré ; seuil du test
0,1 %. Le cadre exclut l’eau animée, qui ne constitue pas une référence
de stabilité pour les blocs statiques.
- Eau : suites fonctionnelles Vulkan et OpenGL réussies
(`build/beta146-water-vulkan.log`, `build/beta146-water-opengl.log`).
Profils, mélange 5/9/17, changement de contexte transmis par le serveur,
préférences séparées, immersion, fond modifié, océans, eau courante,
waterlogging et rechargement. OpenGL contrôle aussi l’égalité des couleurs
aux frontières des copies de région et avec l’échantillonnage de brume.
- `./gradlew check build` exécuté dans un instantané isolé des sources :
252 tests serveur, 229 réussis et 23 échecs. Les identifiants des échecs
correspondent exactement au reçu archivé beta.145 ; aucun nouvel identifiant.
Il ne s’agit pas d’une nouvelle exécution comparative de beta.145.
Journal : `build/beta146-check-build-isolated.log` ; comparaison :
`build/beta146-server-failures.json`. Les derniers changements après cet
instantané concernent le rendu client, ses réglages et ses tests.
Les preuves Vulkan finales sont dans `build/evidence/final-settings-vulkan/`.
La suite de régression PBR et de préférences finale réussit
(`build/beta146-final-pbr-preferences.log`) : PBR 80 %, SSR 20 %, persistance,
migration des anciennes préférences, FR/EN, OFF/zéro et rechargement.
La commande finale `./gradlew check build assemblePack -x :sanctuary:runGameTest`
réussit (125 tâches), sans répéter la suite serveur isolée déjà exécutée.
Journal : `build/beta146-final-build-pack.log`. Cette exclusion ne transforme
pas les 23 échecs serveur en validation globale réussie.
## Artefact local
Le pack [Sanctuary-beta.146.mrpack](../build/Sanctuary-beta.146.mrpack) est exporté
et copié dans le dossier `sanctuary-beta/build/`, à côté des autres versions.
Intégrité ZIP, version beta.146, Minecraft 26.3 et identité du JAR embarqué
avec le JAR construit contrôlées ; shaders finaux présents et identiques aux sources.
Taille : 10 519 634 octets. SHA-256 :
`9f3f7a840652d401741cbe3c85bbb5626326542179754cb599479b01a6b4c955`.
Reçu : `build/beta146-artifacts.json`.
Aucun benchmark isolé de performance de beta.146 n’est revendiqué : des
contrôles serveur peuvent tourner simultanément. Les limites SSR hors écran,
de maillage à grande portée et de compatibilité avec d’autres GPU subsistent.
Aucune publication ou synchronisation Prism.
+6
View File
@@ -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.
+2 -2
View File
@@ -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.142
pack_version=beta.142
mod_version=beta.146
pack_version=beta.146
resource_pack_version=beta.121
maven_group=fr.koka.sanctuary
jei_version=30.32.0-sanctuary.3
+5
View File
@@ -34,6 +34,11 @@ if (clientRenderTests) {
if (improvedTransparency.isPresent()) {
systemProperty('sanctuary.test.improvedTransparency', improvedTransparency.get())
}
systemProperty('sanctuary.test.ssr', providers.gradleProperty('sanctuarySsrClientTests').getOrElse('false'))
systemProperty('sanctuary.test.water144', providers.gradleProperty('sanctuaryWater144ClientTests').getOrElse('false'))
systemProperty('sanctuary.test.water144ExtrasOnly', providers.gradleProperty('sanctuaryWater144ExtrasOnly').getOrElse('false'))
systemProperty('sanctuary.test.water144FunctionalOnly', providers.gradleProperty('sanctuaryWater144FunctionalOnly').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'))
@@ -12,6 +12,7 @@ import java.time.Instant;
import java.time.ZoneOffset;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.BooleanSupplier;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
@@ -50,24 +51,29 @@ public final class Melt109ClientChecks implements FabricClientGameTest {
for(int x=-15;x<=15;x++)for(int z=-15;z<=15;z++)l.setBlockAndUpdate(new BlockPos(x,79,z),Blocks.STONE.defaultBlockState());
clock(l,"2026-07-10T12:00:00Z",SeasonalWeather.Season.SUMMER);
check(l.getGameRules().get(SeasonalSnowMelt.ENABLED),"Melt enabled by default");
check(SeasonalSnowMelt.REALTIME_SCALE==72,"Real-time snow melt scale is one real day per 72 native days");
check(SeasonalSnowMelt.chance(SeasonalWeather.Season.SUMMER)==72
&&SeasonalSnowMelt.chance(SeasonalWeather.Season.SPRING)==144
&&SeasonalSnowMelt.chance(SeasonalWeather.Season.AUTUMN)==288
&&SeasonalSnowMelt.chance(SeasonalWeather.Season.WINTER)==0,"Seasonal melt chances include the 72x real-time scale");
deposit(l,AT,16);check(l.getBlockState(AT.above()).is(Blocks.SNOW_BLOCK),"Default two-block accumulation retained");
clear(l);step(l,AT,1);
check(layers(l,AT.above())==7&&layers(l,AT)==8,"Summer melts only one top layer");
step(l,AT,7);check(l.getBlockState(AT.above()).isAir()&&layers(l,AT)==8,"Top block melts completely before the lower block");
step(l,AT,8);check(l.getBlockState(AT).isAir()&&!tracked(l,AT),"Natural column vanishes without water; receipt removed");
clear(l);
until(l,AT,5000,()->layers(l,AT.above())==7&&layers(l,AT)==8,"Summer melts only one top layer");
until(l,AT,20000,()->l.getBlockState(AT.above()).isAir()&&layers(l,AT)==8,"Top block melts completely before the lower block");
until(l,AT,20000,()->l.getBlockState(AT).isAir()&&!tracked(l,AT),"Natural column vanishes without water; receipt removed");
var built=new BlockPos(7,80,4);l.setBlockAndUpdate(built,Blocks.SNOW_BLOCK.defaultBlockState());step(l,built,64);check(layers(l,built)==8,"Untracked snow block preserved");
var old=new BlockPos(8,80,4);snow(l,old,5);step(l,old,64);check(layers(l,old)==5,"Untracked old layers preserved");
var base=new BlockPos(9,80,4);snow(l,base,3);deposit(l,base,5);clear(l);step(l,base,16);check(layers(l,base)==3&&!tracked(l,base),"Weather above manual layers melts only back to its base");
var atop=new BlockPos(9,80,6);l.setBlockAndUpdate(atop,Blocks.SNOW_BLOCK.defaultBlockState());deposit(l,atop,8);clear(l);step(l,atop,16);check(layers(l,atop)==8&&l.getBlockState(atop.above()).isAir(),"Weather on a snow construction melts; construction stays");
var base=new BlockPos(9,80,4);snow(l,base,3);deposit(l,base,5);clear(l);until(l,base,20000,()->layers(l,base)==3&&!tracked(l,base),"Weather above manual layers melts only back to its base");
var atop=new BlockPos(9,80,6);l.setBlockAndUpdate(atop,Blocks.SNOW_BLOCK.defaultBlockState());deposit(l,atop,8);clear(l);until(l,atop,20000,()->layers(l,atop)==8&&l.getBlockState(atop.above()).isAir(),"Weather on a snow construction melts; construction stays");
var replaced=new BlockPos(10,80,4);deposit(l,replaced,8);l.setBlockAndUpdate(replaced,Blocks.AIR.defaultBlockState());l.setBlockAndUpdate(replaced,Blocks.SNOW_BLOCK.defaultBlockState());clear(l);step(l,replaced,32);check(layers(l,replaced)==8&&!tracked(l,replaced),"Break and replacement cannot inherit melt permission");
var thickened=new BlockPos(10,80,6);deposit(l,thickened,2);snow(l,thickened,6);clear(l);step(l,thickened,32);check(layers(l,thickened)==6&&!tracked(l,thickened),"Manual layer edit relinquishes natural provenance");
var capped=new BlockPos(12,80,6);deposit(l,capped,8);snow(l,capped.above(),1);clear(l);step(l,capped,32);check(layers(l,capped)==8&&layers(l,capped.above())==1,"Manual thin snow cap protects its natural support");
var roof=new BlockPos(11,80,4);deposit(l,roof,8);l.setBlockAndUpdate(roof.above(3),Blocks.STONE.defaultBlockState());clear(l);step(l,roof,32);check(layers(l,roof)==8,"Covered snow remains untouched");l.setBlockAndUpdate(roof.above(3),Blocks.AIR.defaultBlockState());step(l,roof,1);check(layers(l,roof)==7,"Uncovered natural snow resumes melt");
var roof=new BlockPos(11,80,4);deposit(l,roof,8);l.setBlockAndUpdate(roof.above(3),Blocks.STONE.defaultBlockState());clear(l);step(l,roof,32);check(layers(l,roof)==8,"Covered snow remains untouched");l.setBlockAndUpdate(roof.above(3),Blocks.AIR.defaultBlockState());until(l,roof,10000,()->layers(l,roof)==7,"Uncovered natural snow resumes melt");
command(l,"gamerule sanctuary:seasonal_snow_melt false");step(l,roof,32);check(layers(l,roof)==7,"Operator disables melting");command(l,"gamerule sanctuary:seasonal_snow_melt true");
clock(l,"2026-01-10T12:00:00Z",SeasonalWeather.Season.WINTER);clear(l);step(l,roof,64);check(layers(l,roof)==7,"Winter never melts even with clear weather");
clock(l,"2026-04-10T12:00:00Z",SeasonalWeather.Season.SPRING);clear(l);step(l,roof,64);check(l.getBlockState(roof).isAir(),"Spring progressively melts");
deposit(l,roof,8);clock(l,"2026-10-10T12:00:00Z",SeasonalWeather.Season.AUTUMN);clear(l);step(l,roof,128);check(l.getBlockState(roof).isAir(),"Autumn progressively melts");
clock(l,"2026-04-10T12:00:00Z",SeasonalWeather.Season.SPRING);clear(l);until(l,roof,30000,()->l.getBlockState(roof).isAir(),"Spring progressively melts");
deposit(l,roof,8);clock(l,"2026-10-10T12:00:00Z",SeasonalWeather.Season.AUTUMN);clear(l);until(l,roof,50000,()->l.getBlockState(roof).isAir(),"Autumn progressively melts");
deposit(l,roof,8);clock(l,"2026-07-10T12:00:00Z",SeasonalWeather.Season.SUMMER);
command(l,"sanctuary weather snow");l.getWeatherData().setRaining(false);l.setRainLevel(0);step(l,roof,64);check(layers(l,roof)==8,"Forced summer snowfall blocks melting");
command(l,"sanctuary weather snow_showers");l.getWeatherData().setRaining(false);l.setRainLevel(0);step(l,roof,64);check(layers(l,roof)==8,"Pauses in snow showers also block melting");
@@ -84,7 +90,7 @@ public final class Melt109ClientChecks implements FabricClientGameTest {
var l=server.overworld();clock(l,"2026-07-10T12:00:00Z",SeasonalWeather.Season.SUMMER);clear(l);
check(!l.getGameRules().get(SeasonalSnowMelt.ENABLED),"Melt rule persists across restart");
check(layers(l,PERSIST)==8&&layers(l,PERSIST.above())==8&&tracked(l,PERSIST)&&tracked(l,PERSIST.above()),"Chunk attachment survives native save/reload");
command(l,"gamerule sanctuary:seasonal_snow_melt true");step(l,PERSIST,16);
command(l,"gamerule sanctuary:seasonal_snow_melt true");until(l,PERSIST,30000,()->l.getBlockState(PERSIST).isAir()&&l.getBlockState(PERSIST.above()).isAir()&&!tracked(l,PERSIST), "Loaded natural snow melts and clears provenance");
check(l.getBlockState(PERSIST).isAir()&&l.getBlockState(PERSIST.above()).isAir()&&!tracked(l,PERSIST),"Loaded natural snow melts and clears provenance: base="+l.getBlockState(PERSIST)+", top="+l.getBlockState(PERSIST.above())+", sky="+l.canSeeSky(PERSIST.above(2))+", active="+WeatherService.active(l)+", biome="+l.getBiome(PERSIST)+", season="+WeatherService.season(server));
check(layers(l,new BlockPos(7,80,4))==8&&layers(l,new BlockPos(9,80,4))==3,"Built and preexisting snow preserved across restart");
});
@@ -92,13 +98,15 @@ public final class Melt109ClientChecks implements FabricClientGameTest {
System.out.println("MELT109_NATIVE_PASS: accumulation regression, top-down melt, seasons, profiles, cold biomes, constructions, edit invalidation, gamerule, native chunk save/reload");
}finally{intro.set(false);}
}
private static void deposit(ServerLevel l,BlockPos pos,int count){l.getGameRules().set(GameRules.MAX_SNOW_ACCUMULATION_HEIGHT,1,l.getServer());command(l,"sanctuary weather snow");l.getWeatherData().setRaining(true);l.setRainLevel(1);step(l,pos,count);l.getGameRules().set(GameRules.MAX_SNOW_ACCUMULATION_HEIGHT,0,l.getServer());}
private static void deposit(ServerLevel l,BlockPos pos,int count){l.getGameRules().set(GameRules.MAX_SNOW_ACCUMULATION_HEIGHT,1,l.getServer());command(l,"sanctuary weather snow");l.getWeatherData().setRaining(true);l.setRainLevel(1);int target=depth(l,pos)+count;until(l,pos,Math.max(1000,count*200),()->depth(l,pos)>=target,"Weather snow reaches depth "+target+" at "+pos);l.getGameRules().set(GameRules.MAX_SNOW_ACCUMULATION_HEIGHT,0,l.getServer());}
private static void clear(ServerLevel l){command(l,"sanctuary weather clear");l.getWeatherData().setRaining(false);l.setRainLevel(0);}
private static void clock(ServerLevel l,String time,SeasonalWeather.Season expected){try{var field=RealtimeService.Session.class.getDeclaredField("clock");field.setAccessible(true);field.set(RealtimeService.session(l.getServer()),Clock.fixed(Instant.parse(time),ZoneOffset.UTC));check(WeatherService.season(l.getServer())==expected,"Test calendar: "+expected);}catch(ReflectiveOperationException e){throw new AssertionError(e);}}
private static boolean tracked(ServerLevel l,BlockPos p){var m=l.getChunkAt(p).getAttached(SeasonalSnowMelt.NATURAL);return m!=null&&m.containsKey(Long.toString(p.asLong()));}
private static void snow(ServerLevel l,BlockPos p,int layers){l.setBlockAndUpdate(p,Blocks.SNOW.defaultBlockState().setValue(SnowLayerBlock.LAYERS,layers));}
private static int layers(ServerLevel l,BlockPos p){return SeasonalSnowMelt.layers(l.getBlockState(p));}
private static int depth(ServerLevel l,BlockPos p){int total=0;for(int i=0;i<32;i++){int layers=layers(l,p.above(i));if(layers==0)break;total+=layers;}return total;}
private static void step(ServerLevel l,BlockPos p,int n){for(int i=0;i<n;i++)l.tickPrecipitation(p);}
private static void until(ServerLevel l,BlockPos p,int max,BooleanSupplier condition,String message){for(int i=0;i<max&&!condition.getAsBoolean();i++)l.tickPrecipitation(p);check(condition.getAsBoolean(),message);}
private static void command(ServerLevel l,String s){l.getServer().getCommands().performPrefixedCommand(l.getServer().createCommandSourceStack(),s);}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
@@ -6,6 +6,7 @@ import fr.koka.sanctuary.progression.SanctuaryGameplay;
import fr.koka.sanctuary.weather.SnowAccumulation;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.BooleanSupplier;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
@@ -45,42 +46,45 @@ public final class Snow108ClientChecks implements FabricClientGameTest {
if(enabled) {
command(l,"sanctuary weather snow");l.setRainLevel(1);
check(l.getGameRules().get(SnowAccumulation.MAX_HEIGHT)==2,"Default two blocks");
for(int i=1;i<=7;i++){l.tickPrecipitation(AT);check(layers(l,AT)==i,"One layer per precipitation event: "+i);}
check(SnowAccumulation.REALTIME_SCALE==12,"Snow accumulation uses the 12x real-time arrival scale");
for(int i=1;i<=7;i++){int expected=i;until(l,AT,1000,()->layers(l,AT)==expected,"Layer "+i+" arrives through the native sampler");}
var pig=EntityTypes.PIG.create(l,EntitySpawnReason.COMMAND);pig.setNoAi(true);pig.setNoGravity(true);pig.setPos(4.5,80.75,4.5);l.addFreshEntity(pig);
l.tickPrecipitation(AT);check(l.getBlockState(AT).is(Blocks.SNOW_BLOCK),"Eighth layer becomes solid snow block");
until(l,AT,1000,()->l.getBlockState(AT).is(Blocks.SNOW_BLOCK),"Eighth layer becomes solid snow block");
check(l.getBlockState(AT).getCollisionShape(l,AT,CollisionContext.empty()).bounds().maxY==1,"Full block collision");
check(pig.getY()>=81,"Growth lifts a standing entity");pig.discard();
l.tickPrecipitation(AT);check(layers(l,AT.above())==1,"New layer begins above solid snow");
for(int i=0;i<30;i++)l.tickPrecipitation(AT);
until(l,AT,1000,()->layers(l,AT.above())==1,"New layer begins above solid snow");
until(l,AT,2000,()->l.getBlockState(AT.above()).is(Blocks.SNOW_BLOCK),"Default cap fills a second block");
check(l.getBlockState(AT.above()).is(Blocks.SNOW_BLOCK)&&l.getBlockState(AT.above(2)).isAir(),"Default cap at two blocks");
command(l,"gamerule sanctuary:snow_accumulation_height 4");
for(int i=0;i<30;i++)l.tickPrecipitation(AT);
until(l,AT,5000,()->l.getBlockState(AT.above(3)).is(Blocks.SNOW_BLOCK),"Raised cap fills the fourth block");
check(l.getBlockState(AT.above(3)).is(Blocks.SNOW_BLOCK)&&l.getBlockState(AT.above(4)).isAir(),"Operator raises cap to four blocks");
command(l,"gamerule sanctuary:snow_accumulation_height -1");
for(int i=0;i<16;i++)l.tickPrecipitation(AT);
until(l,AT,3000,()->l.getBlockState(AT.above(5)).is(Blocks.SNOW_BLOCK),"Unlimited mode fills beyond the cap");
check(l.getBlockState(AT.above(5)).is(Blocks.SNOW_BLOCK),"Unlimited mode continues upward");
var fresh=new BlockPos(-5,80,-5);command(l,"gamerule sanctuary:snow_accumulation_height 0");l.tickPrecipitation(fresh);check(l.getBlockState(fresh).isAir(),"Sanctuary zero stops deposits");
command(l,"gamerule sanctuary:snow_accumulation_height 2");l.getGameRules().set(GameRules.MAX_SNOW_ACCUMULATION_HEIGHT,0,server);l.tickPrecipitation(fresh);check(l.getBlockState(fresh).isAir(),"Native zero remains authoritative");l.getGameRules().set(GameRules.MAX_SNOW_ACCUMULATION_HEIGHT,1,server);
l.setRainLevel(0);l.getWeatherData().setRaining(false);l.tickPrecipitation(fresh);check(l.getBlockState(fresh).isAir(),"No accumulation between showers");
command(l,"sanctuary weather snow_showers");l.setRainLevel(1);l.getWeatherData().setRaining(true);l.tickPrecipitation(fresh);check(layers(l,fresh)==1,"Snow showers accumulate while falling");
var flower=new BlockPos(-6,80,-6);l.setBlockAndUpdate(flower.below(),Blocks.GRASS_BLOCK.defaultBlockState());l.setBlockAndUpdate(flower,Blocks.DANDELION.defaultBlockState());l.tickPrecipitation(flower);check(l.getBlockState(flower).is(Blocks.DANDELION),"Plant preserved");
var water=new BlockPos(-7,79,-7);l.setBlockAndUpdate(water,Blocks.WATER.defaultBlockState());l.tickPrecipitation(water);check(l.getBlockState(water).is(Blocks.WATER)&&l.getBlockState(water.above()).isAir(),"Open water not filled with snow");
var roof=new BlockPos(7,83,-7);l.setBlockAndUpdate(roof,Blocks.STONE.defaultBlockState());l.tickPrecipitation(roof);check(layers(l,roof.above())==1&&l.getBlockState(roof.below()).isAir(),"Snow on roof, not indoors");
var fresh=new BlockPos(-5,80,-5);command(l,"gamerule sanctuary:snow_accumulation_height 0");step(l,fresh,200);check(l.getBlockState(fresh).isAir(),"Sanctuary zero stops deposits");
command(l,"gamerule sanctuary:snow_accumulation_height 2");l.getGameRules().set(GameRules.MAX_SNOW_ACCUMULATION_HEIGHT,0,server);step(l,fresh,200);check(l.getBlockState(fresh).isAir(),"Native zero remains authoritative");l.getGameRules().set(GameRules.MAX_SNOW_ACCUMULATION_HEIGHT,1,server);
l.setRainLevel(0);l.getWeatherData().setRaining(false);step(l,fresh,200);check(l.getBlockState(fresh).isAir(),"No accumulation between showers");
command(l,"sanctuary weather snow_showers");l.setRainLevel(1);l.getWeatherData().setRaining(true);until(l,fresh,1000,()->layers(l,fresh)==1,"Snow showers accumulate while falling");
var flower=new BlockPos(-6,80,-6);l.setBlockAndUpdate(flower.below(),Blocks.GRASS_BLOCK.defaultBlockState());l.setBlockAndUpdate(flower,Blocks.DANDELION.defaultBlockState());step(l,flower,200);check(l.getBlockState(flower).is(Blocks.DANDELION),"Plant preserved");
var water=new BlockPos(-7,79,-7);l.setBlockAndUpdate(water,Blocks.WATER.defaultBlockState());step(l,water,200);check(l.getBlockState(water).is(Blocks.WATER)&&l.getBlockState(water.above()).isAir(),"Open water not filled with snow");
var roof=new BlockPos(7,83,-7);l.setBlockAndUpdate(roof,Blocks.STONE.defaultBlockState());until(l,roof,1000,()->layers(l,roof.above())==1,"Snow on roof arrives");check(layers(l,roof.above())==1&&l.getBlockState(roof.below()).isAir(),"Snow on roof, not indoors");
var cauldron=new BlockPos(8,80,-8);l.setBlockAndUpdate(cauldron,Blocks.CAULDRON.defaultBlockState());for(int i=0;i<200;i++)l.tickPrecipitation(cauldron);check(l.getBlockState(cauldron).is(Blocks.POWDER_SNOW_CAULDRON),"Native cauldron snow filling preserved");
command(l,"sanctuary weather rain");l.setRainLevel(1);var rainy=new BlockPos(10,80,0);l.tickPrecipitation(rainy);check(l.getBlockState(rainy).isAir(),"Temperate rain is not snow");
command(l,"sanctuary weather vanilla");cold(l);for(int i=0;i<8;i++)l.tickPrecipitation(new BlockPos(0,80,0));check(l.getBlockState(new BlockPos(0,80,0)).is(Blocks.SNOW_BLOCK),"Cold biome also accumulates with vanilla weather");
command(l,"sanctuary weather vanilla");cold(l);until(l,new BlockPos(0,80,0),2000,()->l.getBlockState(new BlockPos(0,80,0)).is(Blocks.SNOW_BLOCK),"Cold biome also accumulates with vanilla weather");
var light=new BlockPos(11,80,11);l.setBlockAndUpdate(light.below(),Blocks.GLOWSTONE.defaultBlockState());command(l,"sanctuary weather snow");l.setRainLevel(1);
} else {
cold(l);for(int i=0;i<40;i++)l.tickPrecipitation(AT);check(layers(l,AT)==1&&l.getBlockState(AT.above()).isAir(),"Non-Sanctuary world keeps native one-layer limit");
}
});
if(enabled){c.waitTicks(8);world.getServer().runOnServer(s->{var l=s.overworld();var light=new BlockPos(11,80,11);check(l.getBrightness(LightLayer.BLOCK,light)>=10,"Test light propagated");for(int i=0;i<16;i++)l.tickPrecipitation(light);check(l.getBlockState(light).isAir(),"Bright surfaces do not accumulate");});}
if(enabled){c.waitTicks(8);world.getServer().runOnServer(s->{var l=s.overworld();var light=new BlockPos(11,80,11);check(l.getBrightness(LightLayer.BLOCK,light)>=10,"Test light propagated");step(l,light,400);check(l.getBlockState(light).isAir(),"Bright surfaces do not accumulate");});}
}
System.out.println("SNOW108_NATIVE_PASS: layers, solid blocks, vertical stacking, entity lift, caps and commands, weather, light, supports, cauldron and vanilla world");
}finally{intro.set(false);}
}
private static void cold(ServerLevel l){command(l,"fillbiome -8 76 -8 8 88 8 minecraft:snowy_plains");l.getWeatherData().setRaining(true);l.setRainLevel(1);}
private static int layers(ServerLevel l,BlockPos p){var s=l.getBlockState(p);return s.is(Blocks.SNOW)?s.getValue(SnowLayerBlock.LAYERS):0;}
private static void step(ServerLevel l,BlockPos p,int n){for(int i=0;i<n;i++)l.tickPrecipitation(p);}
private static void until(ServerLevel l,BlockPos p,int max,BooleanSupplier condition,String message){for(int i=0;i<max&&!condition.getAsBoolean();i++)l.tickPrecipitation(p);check(condition.getAsBoolean(),message);}
private static void command(ServerLevel l,String s){l.getServer().getCommands().performPrefixedCommand(l.getServer().createCommandSourceStack(),s);}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
@@ -30,9 +30,9 @@ public final class Pbr134ClientChecks implements FabricClientGameTest {
c.runOnClient(m->VanillaLight.setPbrIntensity(20));frames(c);var low=difference(off,capture(c,"pbr-20"));
check(delta.total>low.total*1.3,"PBR strength affects actual GPU output");
c.runOnClient(m->VanillaLight.setPbrIntensity(100));frames(c);var high=difference(off,capture(c,"pbr-100"));check(high.total>delta.total*1.2,"Strong PBR visibly increases relief");
c.runOnClient(m->VanillaLight.setPbrIntensity(0));c.waitTicks(8);unchanged(off,capture(c,null),"PBR zero restores native image");
c.runOnClient(m->{VanillaLight.setPbrIntensity(0);});c.waitTicks(8);unchanged(off,capture(c,null),"PBR zero restores native image");
c.runOnClient(m->check(!ProceduralPbr.allocated()&&ProceduralPbr.materialCount()==0,"Zero releases targets and material cache"));
c.runOnClient(m->VanillaLight.setPbrIntensity(50));frames(c);
c.runOnClient(m->{VanillaLight.setPbrIntensity(50);});frames(c);
server.runOnServer(s->command(s,"time set 11000"));settle(c);enable(c,false);var eveningOff=capture(c,null);enable(c,true);var eveningOn=capture(c,"pbr-evening");
int directions=0;for(int i=0;i<on.pixels().length;i++){int am=(on.pixels()[i]>>16&255)-(off.pixels()[i]>>16&255),pm=(eveningOn.pixels()[i]>>16&255)-(eveningOff.pixels()[i]>>16&255);if(Math.abs(am-pm)>5)directions++;}
check(directions>500,"Relief follows the moving sun: "+directions);
@@ -57,12 +57,36 @@ public final class Pbr134ClientChecks implements FabricClientGameTest {
for(int p:PbrMaterials.generate(flat,4,4,profile))check((p>>8&0xFFFF)==0x8080,"Flat source has neutral normal");
flat[5]=0xFFFFFFFF;var slope=PbrMaterials.generate(flat,4,4,profile);check((slope[4]>>16&255)<128&&(slope[6]>>16&255)>128,"Height gradients produce opposite normal slopes");
check(PbrMaterials.profile("iron_block").metal()>.8&&PbrMaterials.profile("iron_ore").metal()==0,"Metal block profile does not turn ore host rock into metal");
check(PbrMaterials.family(Blocks.WATER.defaultBlockState())==2&&PbrMaterials.family(Blocks.GLASS.defaultBlockState())==1&&PbrMaterials.family(Blocks.GLASS_PANE.defaultBlockState())==1,"Water and glass receiver families");
check(PbrMaterials.profile("water").metal()==0&&PbrMaterials.profile("glass").metal()==0,"Water and glass stay dielectric");
int[] glass={0x00FFFFFF,0x00000000,0x00000000,0x00FFFFFF};
for(int p:PbrMaterials.generate(glass,2,2,PbrMaterials.profile("glass")))check((p>>8&0xFFFF)==0x8080,"Transparent glass texels fall back to geometric normal");
check(PbrMaterials.profile("oak_planks").roughness()>PbrMaterials.profile("polished_andesite").roughness(),"Wood remains rougher than polished stone");
}
private static void preferences(){try{
var p=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");Files.writeString(p,"{\"ssgi\":true,\"ssgiIntensity\":35,\"bloom\":false}");forgetPreferences();
check(VanillaLight.pbr()&&VanillaLight.pbrIntensity()==80&&VanillaLight.ssgi()&&!VanillaLight.bloom(),"Missing PBR setting defaults ON while preserving explicit old choices");
for(int n:new int[]{-1,0,50,100,101}){VanillaLight.setPbr(true);VanillaLight.setPbrIntensity(n);forgetPreferences();check(VanillaLight.pbr()&&VanillaLight.pbrIntensity()==Math.clamp(n,0,100),"PBR preference persistence/clamp");}
check(VanillaLight.pbrDistance()==32,"Shared distance default");
check(!VanillaLight.naturalWater(false)&&VanillaLight.naturalWater(true),"World-specific water defaults");
for(int n:new int[]{16,32,64,128}){VanillaLight.setPbrDistance(n);forgetPreferences();check(VanillaLight.pbrDistance()==n,"Shared distance persistence");}
for(int n:new int[]{0,1,2,4,8}){VanillaLight.setWaterBlendRadius(n);forgetPreferences();check(VanillaLight.waterBlendRadius()==n,"Water blend persistence");}
VanillaLight.setPbrDistance(32);VanillaLight.setWaterBlendRadius(4);
check(!VanillaLight.ssr()&&VanillaLight.ssrIntensity()==20,"Missing SSR defaults OFF at 20 percent");
for(int n:new int[]{-1,0,50,100,101}){VanillaLight.setSsr(true);VanillaLight.setSsrIntensity(n);forgetPreferences();check(VanillaLight.ssr()&&VanillaLight.ssrIntensity()==Math.clamp(n,0,100),"SSR preference persistence/clamp");}
VanillaLight.setSsr(false);VanillaLight.setSsrIntensity(20);forgetPreferences();check(!VanillaLight.ssr(),"SSR OFF persists");
var manager=net.minecraft.client.Minecraft.getInstance().getLanguageManager();var oldLanguage=manager.getSelected();
for(String locale:new String[]{"fr_fr","en_us"}){
manager.setSelected(locale);manager.onResourceManagerReload(net.minecraft.client.Minecraft.getInstance().getResourceManager());
for(String key:new String[]{"pbr_distance","pbr_distance_help","pbr_distance_value","water_blend","water_blend_help","water_blend_value","ssr","ssr_help","ssr_intensity","ssr_intensity_help","ssr_intensity_value"}){
String full="sanctuary.shaders."+key;check(!net.minecraft.network.chat.Component.translatable(full,50).getString().equals(full),"SSR FR/EN "+full);
}
}
manager.setSelected(oldLanguage);manager.onResourceManagerReload(net.minecraft.client.Minecraft.getInstance().getResourceManager());
Files.writeString(p,"{\"naturalWater\":false,\"pbrIntensity\":37}");forgetPreferences();
check(!VanillaLight.naturalWater(false)&&!VanillaLight.naturalWater(true)&&VanillaLight.pbrIntensity()==37,"Legacy water OFF and personal PBR intensity are preserved");
Files.writeString(p,"{\"naturalWater\":true}");forgetPreferences();
check(!VanillaLight.naturalWater(false)&&VanillaLight.naturalWater(true),"Legacy global ON adopts contextual defaults");
VanillaLight.setPbrIntensity(50);
}catch(Exception e){throw new AssertionError(e);}}
private record Delta(int changed,int brighter,int darker,long total){}
@@ -0,0 +1,208 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.Blocks;
/** Native screenshots on a new development world; source-colour and transparent receiver checks. */
final class PixelReflectionsClientChecks {
static void run(ClientGameTestContext c,TestServerContext server){
c.runOnClient(m->{VanillaLight.setEnabled(true);VanillaLight.setSaturation(100);VanillaLight.setPbr(true);VanillaLight.setPbrIntensity(80);
VanillaLight.setNaturalWater(true);VanillaLight.setWaterIntensity(100);VanillaLight.setWaterBlendRadius(0);VanillaLight.setSsr(false);VanillaLight.setSsrIntensity(100);VanillaLight.setColoredLights(false);VanillaLight.setSsgi(false);
VanillaLight.setSunshafts(false);VanillaLight.setLensFlare(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);
VanillaLight.setEdgeHighlights(false);VanillaLight.setPixelShadows(false);});
server.runOnServer(s->{
command(s,"time set 6000");
for(int x=-18;x<=18;x++)for(int z=-4;z<=24;z++){
for(int y=65;y<=85;y++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,z),Blocks.AIR.defaultBlockState());
s.overworld().setBlockAndUpdate(new BlockPos(x,64,z),Blocks.STONE.defaultBlockState());
if(z>=3&&z<=18)s.overworld().setBlockAndUpdate(new BlockPos(x,65,z),Blocks.WATER.defaultBlockState());
}
for(int x=-12;x<=12;x++)for(int y=65;y<=77-Math.abs(x)/2;y++)
s.overworld().setBlockAndUpdate(new BlockPos(x,y,19),Blocks.CONCRETE.red().defaultBlockState());
});
camera(c,server,.5,68,0,70);look(c,server,0,12);settle(c);
int analysis=c.computeOnClient(m->ProceduralPbr.waterAnalyses()),updates=c.computeOnClient(m->ProceduralPbr.waterUpdates());
c.waitTicks(20);c.runOnClient(m->check(ProceduralPbr.waterAnalyses()==analysis&&ProceduralPbr.waterUpdates()>updates,"Water normals follow native animation without reanalysis"));
var off=capture(c,"ssr-water-off");
c.runOnClient(m->VanillaLight.setSsr(true));frames(c);var on=capture(c,"ssr-water-on");
dumpLandscape(c);
long changed=changed(off,on);System.out.println("SSR_WATER_DIFF "+changed);
check(changed>100,"SSR changes water receiver pixels: "+changed);
double redSignal=colourSignal(off,on);
server.runOnServer(s->{command(s,"gamemode creative @a");var player=s.getPlayerList().getPlayers().getFirst();player.getAbilities().flying=true;player.onUpdateAbilities();});
c.waitTicks(10);
c.runOnClient(m->m.options.setCameraType(net.minecraft.client.CameraType.THIRD_PERSON_BACK));c.waitTicks(20);
c.runOnClient(m->VanillaLight.setSsr(false));c.waitTicks(8);var f5Off=capture(c,"ssr146-f5-off");
c.runOnClient(m->VanillaLight.setSsr(true));frames(c);var f5On=capture(c,"ssr146-f5-on");
check(changed(f5Off,f5On)>100,"SSR remains visible around third-person player");
int torsoChange=0,torsoPixels=0;
for(int y=f5Off.height()*55/100;y<f5Off.height()*71/100;y++)for(int x=f5Off.width()*485/1000;x<f5Off.width()*515/1000;x++){
int index=y*f5Off.width()+x;torsoPixels++;if(f5Off.pixels()[index]!=f5On.pixels()[index])torsoChange++;
}
check(torsoChange<torsoPixels*.01,"SSR does not draw through the visible F5 torso: "+torsoChange+" / "+torsoPixels);
c.runOnClient(m->m.options.setCameraType(net.minecraft.client.CameraType.FIRST_PERSON));c.waitTicks(20);
server.runOnServer(s->command(s,"gamemode spectator @a"));c.waitTicks(10);
for(int distance:new int[]{16,64,128,32}){
c.runOnClient(m->VanillaLight.setPbrDistance(distance));settle(c);frames(c);capture(c,"ssr146-distance-"+distance);
c.runOnClient(m->check(ProceduralPbr.applied()&&PixelReflections.applied(),"Both material passes share distance "+distance));
}
c.runOnClient(m->{VanillaLight.setPbrIntensity(80);VanillaLight.setSsrIntensity(20);});
camera(c,server,.5,68,10,70);settle(c);frames(c);
var solar=capture(c,"ssr146-solar-water");
int white=0;for(int pixel:solar.pixels())if((pixel>>16&255)>235&&(pixel>>8&255)>235&&(pixel&255)>235)white++;
check(white>300,"Solar reflection has a broad near-white footprint at PBR 80 / SSR 20 percent: "+white);
System.out.println("SSR146_SOLAR_WHITE pixels="+white+" width="+solar.width()+" height="+solar.height());
c.runOnClient(m->VanillaLight.setBloom(true));frames(c);capture(c,"ssr146-solar-water-bloom");
c.runOnClient(m->check(MaterialBloom.contribution()!=null,"Material light reaches bloom"));
c.runOnClient(m->VanillaLight.setBloom(false));frames(c);
c.runOnClient(m->check(MaterialBloom.contribution()==null,"Bloom OFF releases material halo"));
server.runOnServer(s->command(s,"time set 1000"));c.waitTicks(10);
var sunLook=c.computeOnClient(m->{var sun=PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle);return new float[]{(float)Math.toDegrees(Math.atan2(-sun.x,sun.z)),(float)Math.toDegrees(Math.asin(sun.y))};});
look(c,server,sunLook[0],sunLook[1]);frames(c);capture(c,"ssr146-solar-path-low-sun");
c.runOnClient(m->VanillaLight.setBloom(true));frames(c);capture(c,"ssr146-solar-path-low-sun-bloom");
c.runOnClient(m->{VanillaLight.setBloom(false);VanillaLight.setSsrIntensity(100);});
server.runOnServer(s->command(s,"time set 6000"));c.waitTicks(10);
camera(c,server,.5,68,0,70);look(c,server,0,12);settle(c);
server.runOnServer(s->{for(int x=-12;x<=12;x++)for(int y=65;y<=77-Math.abs(x)/2;y++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,19),Blocks.CONCRETE.blue().defaultBlockState());});
settle(c);c.runOnClient(m->VanillaLight.setSsr(false));c.waitTicks(8);var blueOff=capture(c,"ssr-blue-off");
c.runOnClient(m->VanillaLight.setSsr(true));frames(c);var blueOn=capture(c,"ssr-blue-on");double blueSignal=colourSignal(blueOff,blueOn);
System.out.println("SSR_SOURCE_COLOUR red="+redSignal+" blue="+blueSignal);
check(redSignal>blueSignal+3,"Water reflection follows source colour rather than animation: "+redSignal+" / "+blueSignal);
server.runOnServer(s->{for(int x=-12;x<=12;x++)for(int y=65;y<=77-Math.abs(x)/2;y++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,19),Blocks.CONCRETE.red().defaultBlockState());});settle(c);
c.runOnClient(m->VanillaLight.setSsrIntensity(0));c.waitTicks(8);
check(!c.computeOnClient(m->PixelReflections.applied()||PixelReflections.allocated()),"Zero removes SSR contribution and colour allocation");
c.runOnClient(m->VanillaLight.setSsrIntensity(100));frames(c);
for(var bed:new net.minecraft.world.level.block.Block[]{Blocks.SAND,Blocks.MUD}){
server.runOnServer(s->{for(int x=-18;x<=18;x++)for(int z=3;z<=18;z++){s.overworld().setBlockAndUpdate(new BlockPos(x,63,z),Blocks.STONE.defaultBlockState());s.overworld().setBlockAndUpdate(new BlockPos(x,64,z),bed.defaultBlockState());}});
settle(c);frames(c);
c.runOnClient(m->check(NaturalWater.sample(m.level,.5,65.5,8.5).equals(bed==Blocks.SAND?NaturalWater.LIGHT:NaturalWater.POND),"Natural water profile remains independent of PBR and SSR: "+NaturalWater.sample(m.level,.5,65.5,8.5)+" above "+m.level.getBlockState(new BlockPos(0,64,8))));
capture(c,bed==Blocks.SAND?"ssr-sand-profile":"ssr-pond-profile");
}
server.runOnServer(s->{for(int x=-18;x<=18;x++)for(int z=3;z<=18;z++)s.overworld().setBlockAndUpdate(new BlockPos(x,64,z),Blocks.STONE.defaultBlockState());});settle(c);frames(c);
for(float yaw:new float[]{-25,0,25}){look(c,server,yaw,12);frames(c);capture(c,"ssr-water-angle-"+(int)yaw);}
look(c,server,0,2);frames(c);capture(c,"ssr-grazing");
look(c,server,75,12);frames(c);capture(c,"ssr-landscape-offscreen");
camera(c,server,2.5,68,0,70);look(c,server,0,12);settle(c);frames(c);capture(c,"ssr-translated");
camera(c,server,.5,68,0,70);look(c,server,0,12);settle(c);
// A hidden landscape must not leak through an opaque foreground wall.
server.runOnServer(s->{for(int x=-12;x<=12;x++)for(int y=65;y<=78;y++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,18),Blocks.CONCRETE.white().defaultBlockState());});
settle(c);c.runOnClient(m->VanillaLight.setSsr(false));c.waitTicks(8);var coveredOff=capture(c,"ssr-covered-off");
c.runOnClient(m->VanillaLight.setSsr(true));frames(c);var coveredOn=capture(c,"ssr-covered-on");
double coveredSignal=colourSignal(coveredOff,coveredOn);
server.runOnServer(s->{for(int x=-12;x<=12;x++)for(int y=65;y<=77-Math.abs(x)/2;y++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,19),Blocks.CONCRETE.blue().defaultBlockState());});settle(c);
c.runOnClient(m->VanillaLight.setSsr(false));c.waitTicks(8);var hiddenBlueOff=capture(c,"ssr-hidden-blue-off");
c.runOnClient(m->VanillaLight.setSsr(true));frames(c);var hiddenBlueOn=capture(c,"ssr-hidden-blue-on");
double hiddenBlueSignal=colourSignal(hiddenBlueOff,hiddenBlueOn);
check(Math.abs(coveredSignal-hiddenBlueSignal)<1.5,"Hidden landscape colour cannot leak through opaque wall: "+coveredSignal+" / "+hiddenBlueSignal);
server.runOnServer(s->{for(int x=-12;x<=12;x++){
for(int y=65;y<=78;y++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,18),Blocks.AIR.defaultBlockState());
for(int y=65;y<=77-Math.abs(x)/2;y++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,19),Blocks.CONCRETE.red().defaultBlockState());
}});settle(c);frames(c);
benchmark(c,"pond");
look(c,server,0,35);frames(c);benchmark(c,"water-fullscreen");look(c,server,0,12);frames(c);
// The nearest glass layer must replace the water receiver, including alpha-zero glass texels.
server.runOnServer(s->{for(int x=-7;x<=7;x++)for(int z=4;z<=14;z++)s.overworld().setBlockAndUpdate(new BlockPos(x,66,z),Blocks.GLASS.defaultBlockState());});
settle(c);capture(c,"ssr-glass-over-water");
c.runOnClient(m->VanillaLight.setSsr(false));c.waitTicks(8);var glassOff=capture(c,"ssr-glass-off");
c.runOnClient(m->VanillaLight.setSsr(true));frames(c);var glassOn=capture(c,"ssr-glass-on");
check(changed(glassOff,glassOn)>100,"Transparent glass reflects landscape");
benchmark(c,"glass");
server.runOnServer(s->{for(int x=-7;x<=7;x++)for(int z=4;z<=14;z++)s.overworld().setBlockAndUpdate(new BlockPos(x,66,z),Blocks.IRON_BLOCK.defaultBlockState());});
settle(c);c.runOnClient(m->VanillaLight.setSsr(false));c.waitTicks(8);var metalOff=capture(c,"ssr-metal-off");
c.runOnClient(m->VanillaLight.setSsr(true));frames(c);var metalOn=capture(c,"ssr-metal-on");
check(changed(metalOff,metalOn)>100,"Solid PBR reflects landscape");
look(c,server,.00002f,12.00002f);frames(c);var tiny=capture(c,"ssr-metal-tiny-motion");
check(stableMetal(metalOn,tiny),"Tiny camera movement keeps material texels stable");look(c,server,0,12);frames(c);
server.runOnServer(s->{
command(s,"fill -7 67 9 7 70 9 iron_block");
for(int x=-6;x<=6;x+=2)command(s,"setblock "+x+" 67 8 polished_andesite_stairs[facing=south]");
});settle(c);frames(c);var intersections=capture(c,"ssr146-intersections");
look(c,server,.00002f,12.00002f);frames(c);var shifted=capture(c,"ssr146-intersections-tiny-motion");
check(stableIntersections(intersections,shifted),"Block intersections remain stable during subpixel motion");
look(c,server,0,12);frames(c);
c.runOnClient(m->{VanillaLight.setPixelShadows(true);VanillaLight.setSsgi(true);VanillaLight.setColoredLights(true);VanillaLight.setBloom(true);VanillaLight.setSunshafts(true);});
frames(c);capture(c,"ssr-combined");
server.runOnServer(s->command(s,"time set 18000"));c.waitTicks(15);frames(c);capture(c,"ssr-moon");
server.runOnServer(s->command(s,"time set 6000"));c.waitTicks(15);frames(c);
c.runOnClient(m->{VanillaLight.setPixelShadows(false);VanillaLight.setSsgi(false);VanillaLight.setColoredLights(false);VanillaLight.setBloom(false);VanillaLight.setSunshafts(false);});
server.runOnServer(s->{for(int x=-7;x<=7;x++)for(int z=4;z<=14;z++)s.overworld().setBlockAndUpdate(new BlockPos(x,66,z),Blocks.STONE.defaultBlockState());});
settle(c);c.runOnClient(m->VanillaLight.setSsr(false));c.waitTicks(8);capture(c,"ssr-rough-stone-off");
c.runOnClient(m->VanillaLight.setSsr(true));frames(c);capture(c,"ssr-rough-stone-on");
// Native panes, sloped water, cascade sides and a waterlogged slab share the receiver pass.
server.runOnServer(s->{
for(int y=66;y<=69;y++)s.overworld().setBlockAndUpdate(new BlockPos(-8,y,8),Blocks.GLASS_PANE.defaultBlockState());
s.overworld().setBlockAndUpdate(new BlockPos(8,69,8),Blocks.WATER.defaultBlockState());
s.overworld().setBlockAndUpdate(new BlockPos(-8,65,10),Blocks.OAK_SLAB.defaultBlockState().setValue(net.minecraft.world.level.block.state.properties.BlockStateProperties.WATERLOGGED,true));
});settle(c);frames(c);capture(c,"ssr-flow-pane-waterlogged");
server.runOnServer(s->{command(s,"fill -3 66 6 3 69 6 glass");command(s,"fill -3 66 8 3 69 8 light_blue_stained_glass");
command(s,"setblock -4 67 6 blue_stained_glass_pane");});
settle(c);c.runOnClient(m->VanillaLight.setSsr(false));c.waitTicks(8);capture(c,"ssr-stacked-coloured-glass-off");
c.runOnClient(m->VanillaLight.setSsr(true));frames(c);capture(c,"ssr-stacked-coloured-glass-on");
camera(c,server,12,64,8,70);look(c,server,0,0);c.waitTicks(15);
c.runOnClient(m->check(!PixelReflections.allocated(),"Immersion suspends and releases SSR"));
camera(c,server,.5,68,0,70);look(c,server,0,12);settle(c);frames(c);
var reload=c.computeOnClient(net.minecraft.client.Minecraft::reloadResourcePacks);c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();frames(c);capture(c,"ssr-reloaded");
int reloadedUpdates=c.computeOnClient(m->ProceduralPbr.waterUpdates());c.waitTicks(20);
c.runOnClient(m->check(ProceduralPbr.waterUpdates()>reloadedUpdates,"Reloaded atlas supplies a live native water clock"));
int w=c.computeOnClient(m->m.getWindow().getScreenWidth()),h=c.computeOnClient(m->m.getWindow().getScreenHeight());
c.runOnClient(m->m.getWindow().setWindowed(961,541));c.waitTicks(15);frames(c);capture(c,"ssr-resized");
c.runOnClient(m->m.getWindow().setWindowed(w,h));c.waitTicks(15);frames(c);
c.runOnClient(m->VanillaLight.setPbr(false));c.waitTicks(8);c.runOnClient(m->check(!PixelReflections.allocated(),"PBR OFF releases reflection resources"));
System.out.println("SSR_PASS water; transparent glass; solids; OFF/zero; motion; reload; resize");
}
private static boolean stableIntersections(Frame a,Frame b){
// The surrounding animated water is not a stability oracle for static blocks.
int changed=0,count=0;
for(int y=a.height()*30/100;y<a.height()*80/100;y++)for(int x=a.width()/4;x<a.width()*3/4;x++){
int i=y*a.width()+x,aa=a.pixels()[i],bb=b.pixels()[i];count++;
if(Math.abs((aa>>16&255)-(bb>>16&255))+Math.abs((aa>>8&255)-(bb>>8&255))+Math.abs((aa&255)-(bb&255))>12)changed++;
}
System.out.println("SSR146_INTERSECTIONS changed="+changed+" samples="+count);
return changed<count*.001;
}
private static boolean stableMetal(Frame a,Frame b){int changes=0,count=0;
for(int y=a.height()*58/100;y<a.height()*68/100;y++)for(int x=a.width()*3/10;x<a.width()*7/10;x++){
int i=y*a.width()+x,aa=a.pixels()[i],bb=b.pixels()[i];count++;
if(Math.abs((aa>>16&255)-(bb>>16&255))+Math.abs((aa>>8&255)-(bb>>8&255))+Math.abs((aa&255)-(bb&255))>12)changes++;
}return changes<count*.02;
}
private static double colourSignal(Frame a,Frame b){long total=0;int count=0;
for(int y=a.height()/2;y<a.height();y++)for(int x=a.width()/4;x<a.width()*3/4;x++){
int i=y*a.width()+x,aa=a.pixels()[i],bb=b.pixels()[i];total+=(bb>>16&255)-(aa>>16&255)-(bb&255)+(aa&255);count++;
}return total/(double)count;
}
private static void benchmark(ClientGameTestContext c,String name){
c.runOnClient(m->{m.options.enableVsync().set(false);m.options.framerateLimit().set(net.minecraft.client.Options.UNLIMITED_FRAMERATE_CUTOFF);
m.options.inactivityFpsLimit().set(net.minecraft.client.InactivityFpsLimit.MINIMIZED);m.getWindow().setWindowed(1920,1080);});
c.waitTicks(20);frames(c);
c.runOnClient(m->check(m.gameRenderer.mainRenderTarget().width==1920&&m.gameRenderer.mainRenderTarget().height==1080,"Benchmark uses 1080p"));
for(boolean on:new boolean[]{false,true}){
c.runOnClient(m->VanillaLight.setSsr(on));c.waitTicks(30);
c.runOnClient(m->fr.koka.sanctuary.gametest.ReflectionMetrics.start());c.waitTicks(80);
var result=c.computeOnClient(m->fr.koka.sanctuary.gametest.ReflectionMetrics.stop());
c.runOnClient(m->{check(ProceduralPbr.solidBytes()<=24L*1024*1024&&ProceduralPbr.transparentBytes()<=8L*1024*1024,"Mesh memory budgets");
System.out.println("SSR_MEMORY "+name+" enabled="+on+" solid="+ProceduralPbr.solidBytes()+" transparent="+ProceduralPbr.transparentBytes()+" targets="+PixelReflections.targetBytes());});
check(result[0]>=30,"Benchmark has enough frame samples");
System.out.println("SSR_BENCH "+name+" enabled="+on+" count="+result[0]+" medianMs="+result[1]+" p95Ms="+result[2]+" gpuCount="+result[3]+" gpuMedianMs="+result[4]+" gpuP95Ms="+result[5]);
}
}
private static void dumpLandscape(ClientGameTestContext c){
var done=new java.util.concurrent.CompletableFuture<Void>();
c.runOnClient(m->{
var src=PixelReflections.landscape();
var target=new com.mojang.blaze3d.pipeline.TextureTarget("SSR test source",src.getWidth(0),src.getHeight(0),com.mojang.renderpearl.api.GpuFormat.RGBA8_UNORM,null);
com.mojang.blaze3d.systems.RenderSystem.getDevice().createCommandEncoder().copyTextureToTexture(src,target.getColorTexture(),0,0,0,0,0,target.width,target.height);
net.minecraft.client.Screenshot.takeScreenshot(target,image->{try(image){image.writeToFile(java.nio.file.Path.of("screenshots/ssr-opaque-source.png"));done.complete(null);}catch(Exception e){done.completeExceptionally(e);}finally{target.destroyBuffers();}});
});c.waitFor(m->done.isDone(),200);done.join();
}
private static long changed(Frame a,Frame b){long n=0;for(int y=a.height()/2;y<a.height();y++)for(int x=0;x<a.width();x++){
int i=y*a.width()+x,aa=a.pixels()[i],bb=b.pixels()[i];
if(Math.abs((aa>>16&255)-(bb>>16&255))+Math.abs((aa>>8&255)-(bb>>8&255))+Math.abs((aa&255)-(bb&255))>8)n++;
}return n;}
private static void frames(ClientGameTestContext c){int start=c.computeOnClient(m->PixelReflections.frames());c.waitFor(m->PixelReflections.applied()&&ProceduralPbr.ready()&&PixelReflections.frames()>start+8,1200);}
private static void settle(ClientGameTestContext c){c.waitTicks(45);c.waitFor(m->m.levelRenderer.hasRenderedAllSections()&&ProceduralPbr.ready(),1200);c.waitTicks(10);}
}
@@ -151,9 +151,11 @@ 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;}
if(Boolean.getBoolean("sanctuary.test.ssr")){PixelReflectionsClientChecks.run(c,server);return;}
if(pbrOnly){Pbr134ClientChecks.run(c,server);return;}
if(ssgiOnly){Ssgi132ClientChecks.run(c,server);return;}
if(bloomOnly){Bloom130ClientChecks.run(c,server);return;}
@@ -452,7 +454,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
var path=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");
Files.deleteIfExists(path);forgetPreferences();
check(VanillaLight.bloom()&&VanillaLight.bloomIntensity()==20&&VanillaLight.pbr()&&VanillaLight.ssgi(),"Fresh defaults: bloom20, PBR and SSGI ON");
check(VanillaLight.pbrIntensity()==80&&VanillaLight.ssgiIntensity()==35,"PBR default 80, SSGI strength retained");
check(VanillaLight.pbrIntensity()==80&&VanillaLight.ssrIntensity()==20&&VanillaLight.ssgiIntensity()==35,"PBR 80 and SSR 20 defaults, SSGI strength retained");
Files.writeString(path,"{\"pbr\":false,\"ssgi\":false,\"bloomIntensity\":73}");forgetPreferences();
check(!VanillaLight.pbr()&&!VanillaLight.ssgi()&&VanillaLight.bloomIntensity()==73,"Explicit personal toggles and intensity survive changed defaults");
Files.writeString(path,"{\"bloom\":false}");forgetPreferences();
@@ -568,7 +570,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<Frame>();
c.runOnClient(m -> Screenshot.takeScreenshot(m.gameRenderer.mainRenderTarget(), image -> {
try (image) {
@@ -0,0 +1,180 @@
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.setWaterBlendRadius(0);
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<Math.max(blue.x,Math.max(blue.y,blue.z))*.8,"Olive fog stays muted after native vision adaptation");
enable(c,true);extras(c,server);return;
}
if(!Boolean.getBoolean("sanctuary.test.water144PerfOnly")){
c.runOnClient(m->{
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");
});
c.runOnClient(m->check(!NaturalWater.sanctuaryWorld(),"Server identifies ordinary test dimension"));
server.runOnServer(s->net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking.send(s.getPlayerList().getPlayers().getFirst(),new fr.koka.sanctuary.water.WaterWorldSettings.Context(true)));
c.waitTicks(5);c.runOnClient(m->{check(NaturalWater.sanctuaryWorld()&&VanillaLight.naturalWater(),"Sanctuary context defaults ON");VanillaLight.setNaturalWater(false);});
server.runOnServer(s->net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking.send(s.getPlayerList().getPlayers().getFirst(),new fr.koka.sanctuary.water.WaterWorldSettings.Context(false)));
c.waitTicks(5);c.runOnClient(m->check(VanillaLight.naturalWater(),"Ordinary explicit ON is independent of Sanctuary OFF"));
server.runOnServer(s->net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking.send(s.getPlayerList().getPlayers().getFirst(),new fr.koka.sanctuary.water.WaterWorldSettings.Context(true)));
c.waitTicks(5);c.runOnClient(m->{check(!VanillaLight.naturalWater(),"Sanctuary explicit OFF survives context round trip");VanillaLight.setNaturalWater(true);});
server.runOnServer(s->net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking.send(s.getPlayerList().getPlayers().getFirst(),new fr.koka.sanctuary.water.WaterWorldSettings.Context(false)));
c.waitTicks(5);
for(int radius:new int[]{2,4,8}){
c.runOnClient(m->VanillaLight.setWaterBlendRadius(radius));settle(c);
c.runOnClient(m->{
var left=NaturalWater.sample(m.level,79.5,99.1,8.5);
var right=NaturalWater.sample(m.level,80.5,99.1,8.5);
check(left.tint()>.1&&right.tint()<.85&&right.tint()>left.tint(),"Wider transition smooths both sides of section seam");
var west=new NaturalWater.Region(m.level,3,5,-1).corner(m.level,new BlockPos(79,99,8),1,0);
var east=new NaturalWater.Region(m.level,4,5,-1).corner(m.level,new BlockPos(80,99,8),0,0);
var expected=NaturalWater.sample(m.level,80,99,8);
check(Math.abs(west.tint()-east.tint())<.0002&&Math.abs(west.alpha()-east.alpha())<.0002,"Adjacent compiled sections share continuous vertex tint and alpha");
check(Math.abs(west.tint()-expected.tint())<.0002&&Math.abs(west.alpha()-expected.alpha())<.0002,"Compiled surface blend matches immersion sampling");
});
capture(c,"blend146-"+(radius*2+1));
}
c.runOnClient(m->VanillaLight.setWaterBlendRadius(0));settle(c);
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)<difference(off,on)/4,"Zero restores native water (animated texture tolerance)");
c.runOnClient(m->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");
if(!Boolean.getBoolean("sanctuary.test.water144FunctionalOnly"))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<a.pixels().length;i++)value+=channelDifference(a.pixels()[i],b.pixels()[i]);return value;}
private static void extras(ClientGameTestContext c,TestServerContext server){
server.runOnServer(s->{
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<off.pixels().length;i++){int a=off.pixels()[i];int r=a>>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<String,Object>();
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);});}
}
}
@@ -0,0 +1,13 @@
package fr.koka.sanctuary.gametest;
import java.util.ArrayList;
public final class ReflectionMetrics {
private static final ArrayList<Long> samples=new ArrayList<>(),gpu=new ArrayList<>();
public static volatile boolean recording;
public static synchronized void add(long time){if(recording)samples.add(time);}
public static synchronized void gpu(long ns){if(recording)gpu.add(ns);}
public static synchronized void start(){samples.clear();gpu.clear();recording=true;}
public static synchronized double[] stop(){recording=false;var a=samples.stream().mapToLong(Long::longValue).sorted().toArray();
if(a.length==0)throw new AssertionError("No frame timing samples");
var g=gpu.stream().mapToLong(Long::longValue).sorted().toArray();
return new double[]{a.length,a[a.length/2]/1e6,a[Math.min(a.length-1,(int)(a.length*.95))]/1e6,g.length,g.length==0?-1:g[g.length/2]/1e6,g.length==0?-1:g[Math.min(g.length-1,(int)(g.length*.95))]/1e6};}
}
@@ -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<Long> 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<Long> 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;}
}
@@ -0,0 +1,14 @@
package fr.koka.sanctuary.gametest.mixin;
import fr.koka.sanctuary.gametest.ReflectionMetrics;
import net.minecraft.client.Minecraft;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(Minecraft.class)
abstract class ReflectionFrameProbeMixin {
@Unique private long sanctuary$reflectionStart;
@Inject(method="renderFrame",at=@At("HEAD")) private void begin(boolean tick,CallbackInfo ci){if(ReflectionMetrics.recording)sanctuary$reflectionStart=System.nanoTime();}
@Inject(method="renderFrame",at=@At("RETURN")) private void end(boolean tick,CallbackInfo ci){if(ReflectionMetrics.recording)ReflectionMetrics.add(System.nanoTime()-sanctuary$reflectionStart);}
}
@@ -0,0 +1,24 @@
package fr.koka.sanctuary.gametest.mixin;
import com.mojang.blaze3d.systems.TimerQuery;
import fr.koka.sanctuary.client.shader.PixelReflections;
import fr.koka.sanctuary.gametest.ReflectionMetrics;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(value=PixelReflections.class,remap=false)
abstract class ReflectionGpuProbeMixin {
@Unique private static TimerQuery sanctuary$query;
@Unique private static boolean sanctuary$started;
@Inject(method="render",at=@At("HEAD")) private static void begin(CallbackInfo ci){
sanctuary$started=false;
if(!ReflectionMetrics.recording){if(sanctuary$query!=null){sanctuary$query.close();sanctuary$query=null;}return;}
if(sanctuary$query==null)sanctuary$query=new TimerQuery();
if(sanctuary$query.getStatus()==TimerQuery.Status.NOT_RECORDING){
long previous=sanctuary$query.get();if(previous>0)ReflectionMetrics.gpu(previous);
sanctuary$query.beginProfile();sanctuary$started=true;
}
}
@Inject(method="render",at=@At("RETURN")) private static void end(CallbackInfo ci){if(sanctuary$started)sanctuary$query.endProfile();}
}
@@ -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);}
}
@@ -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<SectionCompiler.Results> 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);}
}
}
@@ -10,6 +10,8 @@
"defaultRequire": 1
},
"client": [
"ReflectionFrameProbeMixin",
"ReflectionGpuProbeMixin",
"Colored139LightmapProbeMixin",
"Fourneau094ParticleProbeMixin",
"Tooltip093FontProbeMixin",
@@ -19,6 +21,8 @@
"Atlas026TextureProbeMixin",
"Inventory013MerchantAccess",
"Dynamic030TerrainProbeMixin",
"Star040RenderProbeMixin"
"Star040RenderProbeMixin",
"Water144FrameProbeMixin",
"Water144SectionProbeMixin"
]
}
@@ -71,6 +71,7 @@ public final class SanctuaryMod implements ModInitializer {
fr.koka.sanctuary.notifications.DiscoveryNotifications.register();
fr.koka.sanctuary.realtime.RealtimeService.register();
fr.koka.sanctuary.weather.WeatherService.register();
fr.koka.sanctuary.water.WaterWorldSettings.register();
fr.koka.sanctuary.plans.PlanService.register();
fr.koka.sanctuary.plans.Statuary.register();
fr.koka.sanctuary.food.FoodSync.register();
@@ -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();
@@ -42,8 +42,9 @@ public final class Bloom {
var builder=RenderPipeline.builder().withLocation(id("pipeline/"+name)).withVertexShader("core/screenquad").withFragmentShader(id("core/"+name)).withBindGroupLayout(layout.build());
if(scene)builder.withBindGroupLayout(SCENE);
return RenderPipelines.register(builder.withPrimitiveTopology(PrimitiveTopology.TRIANGLES).withCull(false)
.withColorTargetState(new ColorTargetState(Optional.ofNullable(blend),name.equals("bloom_blur")||name.equals("bloom_downsample")||name.equals("lens_flare_source")?GpuFormat.RGBA16_FLOAT:GpuFormat.RGBA8_UNORM,blend==null?ColorTargetState.WRITE_ALL:ColorTargetState.WRITE_COLOR)).withDepthStencilState(Optional.empty()).build());
.withColorTargetState(new ColorTargetState(Optional.ofNullable(blend),name.equals("bloom_material_add")||name.equals("bloom_blur")||name.equals("bloom_downsample")||name.equals("lens_flare_source")?GpuFormat.RGBA16_FLOAT:GpuFormat.RGBA8_UNORM,blend==null?ColorTargetState.WRITE_ALL:ColorTargetState.WRITE_COLOR)).withDepthStencilState(Optional.empty()).build());
}
private static final RenderPipeline MATERIAL=screen("bloom_material_add",new BlendFunction(BlendFactor.ONE,BlendFactor.ONE,BlendFactor.ZERO,BlendFactor.ONE),false,false);
private static final RenderPipeline SKY=screen("bloom_sky",null,true,false);
private static final RenderPipeline DOWNSAMPLE=screen("bloom_downsample",null,false,false);
private static final RenderPipeline BLUR=screen("bloom_blur",null,false,true);
@@ -121,6 +122,7 @@ public final class Bloom {
}
if(halo){
screen(DOWNSAMPLE,small.getColorTextureView(),emission.getColorTextureView(),false,null);
if(MaterialBloom.contribution()!=null)screen(MATERIAL,small.getColorTextureView(),MaterialBloom.contribution(),false,null);
screen(BLUR,ping.getColorTextureView(),small.getColorTextureView(),false,horizontal);
screen(BLUR,small.getColorTextureView(),ping.getColorTextureView(),false,vertical);
}
@@ -0,0 +1,42 @@
package fr.koka.sanctuary.client.shader;
import com.mojang.blaze3d.pipeline.TextureTarget;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.renderpearl.api.GpuFormat;
import com.mojang.renderpearl.api.pipeline.*;
import com.mojang.renderpearl.api.textures.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.resources.Identifier;
import org.joml.Vector4f;
import java.util.Optional;
/** Extract only the positive contribution of each material pass, before other effects. */
final class MaterialBloom {
private static final RenderPipeline EXTRACT=RenderPipelines.register(RenderPipeline.builder()
.withLocation(Identifier.parse("sanctuary:pipeline/material_bloom"))
.withVertexShader("core/screenquad").withFragmentShader(Identifier.parse("sanctuary:core/material_bloom"))
.withBindGroupLayout(BindGroupLayout.builder().withUniform("Before",UniformType.COMBINED_IMAGE_SAMPLER)
.withUniform("After",UniformType.COMBINED_IMAGE_SAMPLER).build())
.withPrimitiveTopology(PrimitiveTopology.TRIANGLES).withCull(false)
.withColorTargetState(new ColorTargetState(Optional.of(new BlendFunction(BlendFactor.ONE,BlendFactor.ONE,BlendFactor.ZERO,BlendFactor.ONE)),GpuFormat.RGBA16_FLOAT,ColorTargetState.WRITE_COLOR))
.withDepthStencilState(Optional.empty()).build());
private static TextureTarget contribution;
private static boolean ready;
private MaterialBloom(){}
static void beginFrame(){ready=false;if(!VanillaLight.bloom()||VanillaLight.bloomIntensity()==0)release();}
static void accumulate(GpuTextureView before){
if(!VanillaLight.bloom()||VanillaLight.bloomIntensity()==0)return;
var main=Minecraft.getInstance().gameRenderer.mainRenderTarget();
int w=Math.max(1,main.width/8),h=Math.max(1,main.height/8);
if(contribution==null||contribution.width!=w||contribution.height!=h){release();contribution=new TextureTarget("Sanctuary material glow",w,h,GpuFormat.RGBA16_FLOAT,null);}
try(var pass=RenderSystem.getDevice().createCommandEncoder().createRenderPass(()->"Sanctuary material glow",contribution.getColorTextureView(),ready?Optional.empty():Optional.of(new Vector4f()))){
pass.setPipeline(RenderSystem.getCompiledPipeline(EXTRACT));
var nearest=RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST);
pass.setUniform("Before",before,nearest);pass.setUniform("After",main.getColorTextureView(),nearest);pass.draw(3,1,0,0);
}
ready=true;
}
static GpuTextureView contribution(){return ready?contribution.getColorTextureView():null;}
static void release(){if(contribution!=null){contribution.destroyBuffers();contribution=null;}ready=false;}
}
@@ -0,0 +1,198 @@
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<Block,Integer> materials=Map.of();
private static volatile float strength;
private static ClientLevel previousLevel;
private static boolean sanctuaryWorld;
private static volatile int blendRadius=4;
public static boolean sanctuaryWorld(){return sanctuaryWorld;}
private static Profile fogFrom=CLEAR,fogTarget=CLEAR;
private static long fogChanged;
private static boolean wasUnderwater;
private NaturalWater(){}
public static void register(){
net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking.registerGlobalReceiver(
fr.koka.sanctuary.water.WaterWorldSettings.Context.TYPE,(payload,context)->context.client().execute(()->sanctuaryWorld=payload.sanctuary()));
net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents.DISCONNECT.register((listener,client)->sanctuaryWorld=false);
net.fabricmc.fabric.api.client.networking.v1.ClientConfigurationConnectionEvents.INIT.register((listener,client)->sanctuaryWorld=false);
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<Block,Integer>();
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||blendRadius!=VanillaLight.waterBlendRadius()){previousLevel=client.level;strength=next;blendRadius=VanillaLight.waterBlendRadius();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;i<sections.length;i++)if(!sections[i].hasOnlyAir()
&&sections[i].getStates().maybeHas(state->state.getFluidState().is(FluidTags.WATER)))
client.levelExtractor.setSectionDirty(x,level.getSectionYFromSectionIndex(i),z);
}
}
private static Map<Block,Integer> defaults(){
var result=new HashMap<Block,Integer>();
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<BlockPos,BlockState> read,BlockPos pos,Map<Block,Integer> 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;
float tint=0,alpha=0,fog=0;int count=0;
for(int dz=-blendRadius;dz<=blendRadius;dz++)for(int dx=-blendRadius;dx<=blendRadius;dx++){
var q=p.offset(dx,0,dz);
Profile v=!l.hasChunk(q.getX()>>4,q.getZ()>>4)||ocean(l,q)?CLEAR:PROFILES[classify(l::getBlockState,q,materials)];
tint+=v.tint;alpha+=v.alpha;fog+=v.fog;count++;
}
return new Profile(tint/count,alpha/count,fog/count);
}
/** Owned by a single native section compilation, with ocean data captured before worker submission. */
public static final class Region {
private static final int PAD=9,SIDE=34;
private final int radius=blendRadius;
private final float[][][] sums=new float[16][][];
private final int ox,oy,oz;
private final net.minecraft.world.level.biome.BiomeManager biomes;
private byte[] oceanKinds;
private byte[] profiles;
private final Map<Block,Integer> 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<net.minecraft.core.Holder<net.minecraft.world.level.biome.Biome>>(600);
int qx=(ox>>2)-3,qy=(oy>>2)-1,qz=(oz>>2)-3;
for(int y=0;y<6;y++)for(int z=0;z<10;z++)for(int x=0;x<10;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)*10+z-qz)*10+x-qx));
}
public boolean ocean(int x,int y,int z){
if(oceanKinds==null)oceanKinds=new byte[SIDE*16*SIDE];
int key=((y-oy)*SIDE+z-oz+PAD)*SIDE+x-ox+PAD;
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(x<ox-PAD||x>=ox-PAD+SIDE||y<oy||y>oy+15||z<oz-PAD||z>=oz-PAD+SIDE)return CLEAR;
if(ocean(x,y,z))return CLEAR;
int key=((y-oy)*SIDE+z-oz+PAD)*SIDE+x-ox+PAD;
if(profiles==null)profiles=new byte[SIDE*16*SIDE];
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];
}
private Profile blended(BlockAndTintGetter region,int x,int y,int z){
if(radius==0)return profile(region,x,y,z);
int layer=y-oy,stride=SIDE+1;
if(sums[layer]==null){
var sum=new float[3][stride*stride];
for(int iz=0;iz<SIDE;iz++)for(int ix=0;ix<SIDE;ix++){
var p=profile(region,ox-PAD+ix,y,oz-PAD+iz);int at=(iz+1)*stride+ix+1;
float[] values={p.tint,p.alpha,p.fog};
for(int c=0;c<3;c++)sum[c][at]=values[c]+sum[c][at-1]+sum[c][at-stride]-sum[c][at-stride-1];
}
sums[layer]=sum;
}
int left=x-ox+PAD-radius,top=z-oz+PAD-radius,right=left+radius*2+1,bottom=top+radius*2+1;
float[] values=new float[3];float area=(radius*2+1)*(radius*2+1);
for(int c=0;c<3;c++){var a=sums[layer][c];values[c]=(a[bottom*stride+right]-a[top*stride+right]-a[bottom*stride+left]+a[top*stride+left])/area;}
return new Profile(values[0],values[1],values[2]);
}
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 blended(region,x,y,z).mix(blended(region,x+1,y,z),.5f).mix(blended(region,x,y,z+1).mix(blended(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;
int margin=blendRadius+1;
renderer.setSectionRangeDirty((pos.getX()-margin)>>4,pos.getY()>>4,(pos.getZ()-margin)>>4,
(pos.getX()+margin)>>4,(pos.getY()+DEPTH)>>4,(pos.getZ()+margin)>>4);
}
}
@@ -0,0 +1,18 @@
package fr.koka.sanctuary.client.shader;
import java.lang.ref.WeakReference;
import java.util.Map;
import java.util.WeakHashMap;
import net.minecraft.client.renderer.texture.SpriteContents;
/** References the actual atlas clock, so pause, reload and custom frame durations stay aligned. */
public final class PbrAnimation {
public interface Clock { int sanctuary$frame(); int sanctuary$subFrame(); }
// AnimationState retains its owning sprite; both sides must be weak across atlas reloads.
private static final Map<SpriteContents,WeakReference<Clock>> clocks=new WeakHashMap<>();
private PbrAnimation(){}
public static void register(SpriteContents sprite,SpriteContents.AnimationState state){
if(state!=null)clocks.put(sprite,new WeakReference<>((Clock)state));
}
static Clock clock(SpriteContents sprite){var clock=clocks.get(sprite);return clock==null?null:clock.get();}
}
@@ -0,0 +1,52 @@
package fr.koka.sanctuary.client.shader;
import com.mojang.blaze3d.vertex.VertexConsumer;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.block.FluidRenderer;
import net.minecraft.client.renderer.texture.TextureAtlasSprite;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.state.BlockState;
/** Replays the native fluid tessellator, including slope, flow UVs and waterlogged faces. */
final class PbrFluidMesh implements VertexConsumer {
private final VertexConsumer target;
private final PbrMaterials materials;
private final TextureAtlasSprite[] sprites;
private final float[][] vertices=new float[4][5];
private final int sky;
private int cursor,quads;
private PbrFluidMesh(VertexConsumer target,PbrMaterials materials,TextureAtlasSprite[] sprites,int sky){
this.target=target;this.materials=materials;this.sprites=sprites;this.sky=sky;
}
static int emit(VertexConsumer target,PbrMaterials materials,net.minecraft.client.multiplayer.ClientLevel level,BlockPos pos,BlockState state){
var models=Minecraft.getInstance().getModelManager().getFluidStateModelSet();
var model=models.get(state.getFluidState());
var sink=new PbrFluidMesh(target,materials,new TextureAtlasSprite[]{model.stillMaterial().sprite(),model.flowingMaterial().sprite(),model.overlayMaterial()==null?null:model.overlayMaterial().sprite()},
level.getBrightness(net.minecraft.world.level.LightLayer.SKY,pos.above()));
new FluidRenderer(models).tesselate(level,pos,layer->sink,state,state.getFluidState());
return sink.quads;
}
@Override public void addVertex(float x,float y,float z,int color,float u,float v,int overlay,int light,float nx,float ny,float nz){
vertices[cursor++]=new float[]{x,y,z,u,v};
if(cursor!=4)return;
cursor=0;float cu=0,cv=0;for(var p:vertices){cu+=p[3]*.25f;cv+=p[4]*.25f;}
TextureAtlasSprite sprite=null;
for(var candidate:sprites)if(candidate!=null&&cu>=candidate.getU0()&&cu<=candidate.getU1()&&cv>=candidate.getV0()&&cv<=candidate.getV1()){sprite=candidate;break;}
if(sprite==null)return;
var slot=materials.slot(sprite,net.minecraft.world.level.block.Blocks.WATER.defaultBlockState());
if(slot.empty())return;
for(var p:vertices)target.addVertex(p[0],p[1],p[2]).setColor((Math.round(sky/15f*255)<<24)|(color&0xFFFFFF))
.setUv(p[3],p[4]).setUv3(slot.u((p[3]-sprite.getU0())/(sprite.getU1()-sprite.getU0())),slot.v((p[4]-sprite.getV0())/(sprite.getV1()-sprite.getV0()))+4);
quads++;
}
// FluidRenderer uses the packed overload above. Fail visibly if that native contract changes.
public VertexConsumer addVertex(float x,float y,float z){throw new UnsupportedOperationException("Unpacked fluid vertex");}
public VertexConsumer setColor(int r,int g,int b,int a){return this;}
public VertexConsumer setColor(int c){return this;}
public VertexConsumer setUv(float u,float v){return this;}
public VertexConsumer setUv1(int u,int v){return this;}
public VertexConsumer setUv2(int u,int v){return this;}
public VertexConsumer setUv3(float u,float v){return this;}
public VertexConsumer setNormal(float x,float y,float z){return this;}
public VertexConsumer setLineWidth(float w){return this;}
}
@@ -17,10 +17,18 @@ final class PbrMaterials implements AutoCloseable {
}
private final Map<String,Slot> slots=new HashMap<>();
private final Map<BlockState,Profile> profiles=new IdentityHashMap<>();
private final List<WaterAnimation> animations=new ArrayList<>();
private DynamicTexture atlas;private boolean dirty;private int analyses;
boolean candidate(BlockState state){return !state.isAir()&&!state.liquid();}
static int family(BlockState state){
String name=BuiltInRegistries.BLOCK.getKey(state.getBlock()).getPath();
return state.getFluidState().is(net.minecraft.tags.FluidTags.WATER)&&state.liquid()?2:
(name.equals("glass")||name.endsWith("_glass")||name.equals("glass_pane")||name.endsWith("_glass_pane"))?1:0;
}
boolean candidate(BlockState state){return !state.isAir();}
Profile profile(BlockState state){return profiles.computeIfAbsent(state,s->profile(BuiltInRegistries.BLOCK.getKey(s.getBlock()).getPath()));}
static Profile profile(String name){
if(name.equals("water"))return new Profile(.12f,0);
if(name.contains("glass"))return new Profile(.16f,0);
if(name.equals("iron_block")||name.equals("gold_block")||name.equals("netherite_block")||name.contains("copper")&&!name.contains("ore")&&!name.contains("raw"))
return new Profile(name.contains("oxidized")?.65f:.32f,name.contains("oxidized")?.35f:.85f);
if(name.contains("polished")||name.contains("glazed")||name.equals("diamond_block")||name.equals("emerald_block")||name.equals("ruby_block")||name.equals("sapphire_block"))return new Profile(.42f,0);
@@ -39,7 +47,13 @@ final class PbrMaterials implements AutoCloseable {
int fw=Math.min(image.getWidth(),sprite.contents().width()),fh=Math.min(image.getHeight(),sprite.contents().height());
int[] colors=new int[INNER*INNER];
for(int py=0;py<INNER;py++)for(int px=0;px<INNER;px++)colors[py*INNER+px]=image.getPixel(px*fw/INNER,py*fh/INNER);
int family=family(state);
// Pigmented glass can have low alpha and still carry real texture relief.
// Fully transparent glass texels keep the geometric-normal fallback.
if(family==2||family==1)for(int j=0;j<colors.length;j++)
if(family==2||(colors[j]>>>24)>0)colors[j]|=0xFF000000;
int[] pixels=generate(colors,INNER,INNER,profile);analyses++;
if(family(state)==2)animations.add(new WaterAnimation(sprite,image,profile,x,y));
for(int py=0;py<TILE;py++)for(int px=0;px<TILE;px++)atlas.getPixels().setPixel(x+px,y+py,pixels[Math.clamp(py-2,0,INNER-1)*INNER+Math.clamp(px-2,0,INNER-1)]);
}catch(java.io.IOException e){empty=true;fr.koka.sanctuary.SanctuaryMod.LOGGER.warn("Cannot analyse PBR texture {}",sprite.contents().name(),e);}
var result=new Slot(x,y,empty);slots.put(key,result);dirty=true;return result;
@@ -59,7 +73,52 @@ final class PbrMaterials implements AutoCloseable {
}
private static float height(int color,float fallback){return (color>>>24)<128?fallback:luma(color);}
private static float luma(int c){return ((c>>16&255)*.2126f+(c>>8&255)*.7152f+(c&255)*.0722f)/255;}
DynamicTexture atlas(){if(dirty){atlas.upload();dirty=false;}return atlas;}
DynamicTexture atlas(){
if(dirty){atlas.upload();dirty=false;for(var animation:animations)animation.lastFrame=-1;}
if(atlas!=null)for(var animation:animations)animation.update(atlas);
return atlas;
}
private static final class WaterAnimation {
final TextureAtlasSprite sprite;final int x,y;final int[][] normals;final int[] frames,times;final boolean interpolate;
final NativeImage tile=new NativeImage(TILE,TILE,false);int lastFrame=-1,lastSub=-1,updates;
WaterAnimation(TextureAtlasSprite sprite,NativeImage image,Profile profile,int x,int y)throws java.io.IOException{
this.sprite=sprite;this.x=x;this.y=y;
int w=sprite.contents().width(),h=sprite.contents().height(),columns=image.getWidth()/w,count=columns*(image.getHeight()/h);
// Bound custom resource-pack memory; unsupported huge animations keep their initial normal.
boolean capped=count>1024;
if(capped){fr.koka.sanctuary.SanctuaryMod.LOGGER.warn("PBR water animation {} exceeds 1024 frames; using a fixed normal",sprite.contents().name());count=1;}
normals=new int[count][];
for(int f=0;f<count;f++){
int[] colors=new int[INNER*INNER];
for(int py=0;py<INNER;py++)for(int px=0;px<INNER;px++)colors[py*INNER+px]=image.getPixel((f%columns)*w+px*w/INNER,(f/columns)*h+py*h/INNER)|0xFF000000;
normals[f]=generate(colors,INNER,INNER,profile);
}
var metadata=net.minecraft.client.Minecraft.getInstance().getResourceManager().getResourceOrThrow(BloomMaterials.texture(sprite.contents().name()))
.metadata().getSection(net.minecraft.client.resources.metadata.animation.AnimationMetadataSection.TYPE);
var sequence=new ArrayList<Integer>();var durations=new ArrayList<Integer>();
if(metadata.isPresent()&&!capped){
var m=metadata.get();interpolate=m.interpolatedFrames();
if(m.frames().isPresent())for(var frame:m.frames().get()){
if(frame.index()>=0&&frame.index()<count&&frame.timeOr(m.defaultFrameTime())>0){sequence.add(frame.index());durations.add(frame.timeOr(m.defaultFrameTime()));}
}else for(int f=0;f<count;f++){sequence.add(f);durations.add(m.defaultFrameTime());}
}else interpolate=false;
if(sequence.isEmpty()){sequence.add(0);durations.add(1);}
frames=sequence.stream().mapToInt(Integer::intValue).toArray();times=durations.stream().mapToInt(Integer::intValue).toArray();
}
void update(DynamicTexture atlas){
var clock=PbrAnimation.clock(sprite.contents());int f=clock==null?0:Math.floorMod(clock.sanctuary$frame(),frames.length),sub=clock==null||!interpolate?0:clock.sanctuary$subFrame();
if(f==lastFrame&&sub==lastSub)return;lastFrame=f;lastSub=sub;updates++;
float t=interpolate?Math.clamp(sub/(float)times[f],0,1):0;
int[] a=normals[frames[f]],b=normals[frames[(f+1)%frames.length]];
for(int py=0;py<TILE;py++)for(int px=0;px<TILE;px++){
int i=Math.clamp(py-2,0,INNER-1)*INNER+Math.clamp(px-2,0,INNER-1);
int r=Math.round((a[i]>>16&255)*(1-t)+(b[i]>>16&255)*t),g=Math.round((a[i]>>8&255)*(1-t)+(b[i]>>8&255)*t);
tile.setPixel(px,py,(a[i]&0xFF0000FF)|(r<<16)|(g<<8));
}
com.mojang.blaze3d.systems.RenderSystem.getDevice().createCommandEncoder().writeToTexture(atlas.getTexture(),tile,0,0,x,y);
}
}
int animationUpdates(){return animations.stream().mapToInt(a->a.updates).sum();}
int count(){return slots.size();}int analyses(){return analyses;}
public void close(){if(atlas!=null){atlas.close();atlas=null;}slots.clear();profiles.clear();dirty=false;analyses=0;}
public void close(){if(atlas!=null){atlas.close();atlas=null;}for(var animation:animations)animation.tile.close();animations.clear();slots.clear();profiles.clear();dirty=false;analyses=0;}
}
@@ -25,7 +25,14 @@ final class PbrTerrain implements AutoCloseable {
static final VertexFormat FORMAT=VertexFormat.builder(0).addAttribute("Position",GpuFormat.RGB32_FLOAT)
.addAttribute("Color",GpuFormat.RGBA8_UNORM).addAttribute("UV0",GpuFormat.RG32_FLOAT)
.addAttribute("UV3",GpuFormat.RG32_FLOAT).build();
private static final long MAX_BYTES=24L*1024*1024;
private final boolean transparent;
private final long maxBytes;
PbrTerrain(){this(false);}
PbrTerrain(boolean transparent){this.transparent=transparent;maxBytes=(transparent?8L:24L)*1024*1024;}
private boolean candidate(net.minecraft.world.level.block.state.BlockState state){
return transparent ? PbrMaterials.family(state)==1||state.getFluidState().is(net.minecraft.tags.FluidTags.WATER)
: materials.candidate(state)&&!state.liquid()&&PbrMaterials.family(state)==0;
}
private final Map<Long,Section> sections=new HashMap<>();
final PbrMaterials materials=new PbrMaterials();
private Build building;
@@ -41,7 +48,7 @@ final class PbrTerrain implements AutoCloseable {
long frame=mc.level.getGameTime();var active=new ArrayList<Section>();
for(var render:mc.levelRenderer.visibleSections()){
long key=render.getSectionNode();var pos=render.getRenderOrigin();
if(pos.offset(8,8,8).distToCenterSqr(camera)>48*48)continue;
if(pos.offset(8,8,8).distToCenterSqr(camera)>Math.pow(VanillaLight.pbrDistance()+16,2))continue;
var chunk=mc.level.getChunkSource().getChunk(pos.getX()>>4,pos.getZ()>>4,ChunkStatus.FULL,false);
if(chunk==null)continue;
var storage=chunk.getSection(mc.level.getSectionIndex(pos.getY()));
@@ -61,7 +68,7 @@ final class PbrTerrain implements AutoCloseable {
if(started++>=4)break;
Section next=active.stream().filter(s->s.dirty).min(Comparator.comparingDouble(s->s.origin.distToCenterSqr(camera))).orElse(null);
if(next==null)break;
if(!next.storage.maybeHas(materials::candidate)){releaseEntry(next);next.dirty=false;continue;}
if(!next.storage.maybeHas(this::candidate)){releaseEntry(next);next.dirty=false;continue;}
building=new Build(next);
}
if(!advance(building,deadline))break;
@@ -73,7 +80,7 @@ final class PbrTerrain implements AutoCloseable {
try(var mesh=done.out.buildOrThrow()){
long size=mesh.vertexBuffer().remaining();
long replaced=done.section.entry==null?0:done.section.entry.vertices.size();
if(bytes-replaced+size<=MAX_BYTES){
if(bytes-replaced+size<=maxBytes){
var buffer=RenderSystem.getDevice().createBuffer(()->"Sanctuary PBR surfaces",GpuBuffer.USAGE_VERTEX|GpuBuffer.USAGE_COPY_DST,mesh.vertexBuffer());
int count=mesh.drawState().indexCount();RenderSystem.getSequentialBuffer(PrimitiveTopology.QUADS).getBuffer(count);
replacement=new Entry(done.section.origin,buffer,count);
@@ -94,7 +101,13 @@ final class PbrTerrain implements AutoCloseable {
var parts=new ArrayList<BlockStateModelPart>();var random=RandomSource.create();
while(build.cursor<4096){
int i=build.cursor++,x=i&15,z=i>>4&15,y=i>>8;var state=build.section.storage.getBlockState(x,y,z);
if(materials.candidate(state)){
if(candidate(state)){
if(transparent&&state.getFluidState().is(net.minecraft.tags.FluidTags.WATER)){
int emitted=PbrFluidMesh.emit(build.out,materials,mc.level,build.section.origin.offset(x,y,z),state);
build.quads+=emitted;
if(build.quads>=16384){build.overflow=true;return true;}
}
if(state.liquid() || (transparent&&PbrMaterials.family(state)!=1)){if(System.nanoTime()>=deadline)return build.cursor==4096;continue;}
var pos=build.section.origin.offset(x,y,z);
parts.clear();random.setSeed(state.getSeed(pos));if(!state.liquid())models.get(state).collectParts(random,parts);
var tintSources=mc.getBlockColors().getTintSources(state);
@@ -116,7 +129,7 @@ final class PbrTerrain implements AutoCloseable {
var p=quad.position(v);float u=UVPair.unpackU(quad.packedUV(v)),tv=UVPair.unpackV(quad.packedUV(v));
float localU=(u-sprite.getU0())/(sprite.getU1()-sprite.getU0()),localV=(tv-sprite.getV0())/(sprite.getV1()-sprite.getV0());
build.out.addVertex(x+p.x()+(float)offset.x,y+p.y()+(float)offset.y,z+p.z()+(float)offset.z)
.setColor(color).setUv(u,tv).setUv3(slot.u(localU),slot.v(localV));
.setColor(color).setUv(u,tv).setUv3(slot.u(localU),slot.v(localV)+2*PbrMaterials.family(state));
}
build.quads++;
}
@@ -133,6 +146,7 @@ final class PbrTerrain implements AutoCloseable {
private void dirty(Section section){if(section!=null){section.revision++;section.dirty=true;}}
private void releaseEntry(Section section){if(section.entry!=null){bytes-=section.entry.vertices.size();section.entry.vertices.close();section.entry=null;}}
private void remove(Section section){if(building!=null&&building.section==section){building.close();building=null;}releaseEntry(section);}
long allocatedBytes(){return bytes;}
int entryCount(){return (int)visible.stream().filter(s->s.entry!=null).count();}
int completed(){return completed;}
boolean building(){return building!=null;}
@@ -0,0 +1,151 @@
package fr.koka.sanctuary.client.shader;
import com.mojang.blaze3d.buffers.Std140Builder;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.renderpearl.api.GpuFormat;
import com.mojang.renderpearl.api.buffers.GpuBuffer;
import com.mojang.renderpearl.api.commands.RenderPassDescriptor;
import com.mojang.renderpearl.api.pipeline.*;
import com.mojang.renderpearl.api.textures.*;
import fr.koka.sanctuary.client.panorama.PanoramaCapture;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.BindGroupLayouts;
import net.minecraft.client.renderer.MappableRingBuffer;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.client.renderer.texture.TextureAtlas;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.material.FogType;
import org.joml.Matrix4f;
import org.joml.Vector3f;
import org.joml.Vector4f;
import java.util.*;
/** Bounded single-bounce landscape reflection. Never samples the target being written. */
public final class PixelReflections {
private static final BindGroupLayout LAYOUT=BindGroupLayout.builder()
.withUniform("ReflectionScene",UniformType.UNIFORM_BUFFER)
.withUniform("OpaqueDepth",UniformType.COMBINED_IMAGE_SAMPLER)
.withUniform("OpaqueColor",UniformType.COMBINED_IMAGE_SAMPLER)
.withUniform("ReceiverDepth",UniformType.COMBINED_IMAGE_SAMPLER)
.withUniform("SceneColor",UniformType.COMBINED_IMAGE_SAMPLER)
.withUniform("BlockAtlas",UniformType.COMBINED_IMAGE_SAMPLER)
.withUniform("MaterialAtlas",UniformType.COMBINED_IMAGE_SAMPLER).build();
private static RenderPipeline pipeline(String name,boolean depth){
var b=RenderPipeline.builder().withLocation(Identifier.parse("sanctuary:pipeline/"+name))
.withVertexShader(Identifier.parse("sanctuary:core/pbr"))
.withFragmentShader(Identifier.parse("sanctuary:core/"+name))
.withBindGroupLayout(BindGroupLayouts.DYNAMIC_TRANSFORMS).withBindGroupLayout(LAYOUT)
.withVertexBinding(0,PbrTerrain.FORMAT).withPrimitiveTopology(PrimitiveTopology.QUADS).withCull(true);
if(depth)b.withDepthStencilState(new DepthStencilState(CompareOp.GREATER_THAN,true));
else b.withColorTargetState(new ColorTargetState(Optional.empty(),GpuFormat.RGBA8_UNORM,ColorTargetState.WRITE_COLOR)).withDepthStencilState(new DepthStencilState(CompareOp.EQUAL,false));
return RenderPipelines.register(b.build());
}
private static final RenderPipeline DEPTH=pipeline("pbr_receiver",true),REFLECT=pipeline("pixel_reflections",false);
private static GpuTexture opaqueDepth,opaqueColor;
private static GpuTextureView opaqueDepthView,opaqueColorView;
private static int width,height;
private static GpuTexture receiver;
private static GpuTextureView receiverView;
private static MappableRingBuffer uniform;
private static Matrix4f vp=new Matrix4f();
private static List<PbrTerrain.Entry> solid=List.of(),transparent=List.of();
private static DynamicTexture solidAtlas,transparentAtlas;
private static boolean captured,ready,applied;
private static int frames;
private PixelReflections(){}
public static boolean eligible(){
var mc=Minecraft.getInstance();
return mc.level!=null&&VanillaLight.active()&&VanillaLight.pbr()&&VanillaLight.pbrIntensity()>0&&!PanoramaCapture.rendering()
&&mc.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.fogType==FogType.NONE
&&mc.gameRenderer.gameRenderState().levelRenderState.skyRenderState.skybox==net.minecraft.world.level.dimension.DimensionType.Skybox.OVERWORLD;
}
public static void beginFrame(){MaterialBloom.beginFrame();captured=ready=applied=false;if(!eligible())release();}
/** Called only with the opaque render pass closed, on both native transparency paths. */
public static void captureOpaque(){
if(!eligible())return;
var mc=Minecraft.getInstance();var main=mc.gameRenderer.mainRenderTarget();
var device=RenderSystem.getDevice();
int usage=GpuTexture.USAGE_RENDER_ATTACHMENT|GpuTexture.USAGE_TEXTURE_BINDING|GpuTexture.USAGE_COPY_DST|GpuTexture.USAGE_COPY_SRC;
if(opaqueDepth==null||width!=main.width||height!=main.height){
release();width=main.width;height=main.height;
opaqueDepth=device.createTexture(()->"Sanctuary opaque depth",usage,main.getDepthTexture().getFormat(),width,height,1,1);
opaqueDepthView=device.createTextureView(opaqueDepth);
receiver=device.createTexture(()->"Sanctuary nearest PBR surface",usage,main.getDepthTexture().getFormat(),width,height,1,1);
receiverView=device.createTextureView(receiver);
}
boolean enabled=VanillaLight.ssr()&&VanillaLight.ssrIntensity()>0;
if(enabled&&opaqueColor==null){
opaqueColor=device.createTexture(()->"Sanctuary reflection landscape",usage,GpuFormat.RGBA8_UNORM,width,height,1,1);
opaqueColorView=device.createTextureView(opaqueColor);
}else if(!enabled&&opaqueColor!=null){opaqueColorView.close();opaqueColor.close();opaqueColorView=null;opaqueColor=null;}
var encoder=device.createCommandEncoder();
encoder.copyTextureToTexture(main.getDepthTexture(),opaqueDepth,0,0,0,0,0,width,height);
if(enabled)encoder.copyTextureToTexture(main.getColorTexture(),opaqueColor,0,0,0,0,0,width,height);
captured=true;
}
static void prepare(Matrix4f matrix,List<PbrTerrain.Entry> solids,DynamicTexture sa,List<PbrTerrain.Entry> liquids,DynamicTexture ta){
if(!captured)return;
vp.set(matrix);solid=solids;transparent=liquids;solidAtlas=sa;transparentAtlas=ta;
if(uniform==null)uniform=new MappableRingBuffer(()->"Sanctuary reflection parameters",GpuBuffer.USAGE_UNIFORM|GpuBuffer.USAGE_MAP_WRITE,160);
uniform.rotate();try(var map=uniform.currentBuffer().map(false,true)){
Std140Builder.intoBuffer(map.data()).putMat4f(vp).putMat4f(new Matrix4f(vp).invert())
.putVec4(RenderSystem.getDevice().getDeviceInfo().isZZeroToOne()?1:0,VanillaLight.ssrIntensity()/100f,VanillaLight.pbrIntensity()/100f,VanillaLight.pbrDistance())
.putVec4(128,32,0,0);
}
int max=java.util.stream.Stream.concat(solid.stream(),transparent.stream()).mapToInt(PbrTerrain.Entry::indices).max().orElse(0);
RenderSystem.getSequentialBuffer(PrimitiveTopology.QUADS).requestIndexCount(max);
var encoder=RenderSystem.getDevice().createCommandEncoder();
try(var pass=encoder.createRenderPass(RenderPassDescriptor.builder(()->"Sanctuary nearest material")
.withDepthAttachment(receiverView,OptionalDouble.of(0)).build())){
pass.setPipeline(RenderSystem.getCompiledPipeline(DEPTH));
bind(pass,Minecraft.getInstance().gameRenderer.mainRenderTarget().getColorTextureView(),false);
draw(pass,solid,solidAtlas);draw(pass,transparent,transparentAtlas);
}
ready=true;
}
static GpuTextureView depth(){return ready?receiverView:null;}
private static void bind(com.mojang.renderpearl.api.commands.RenderPass pass,GpuTextureView scene,boolean reflection){
var nearest=RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST);
pass.setUniform("ReflectionScene",uniform.currentBuffer());
pass.setUniform("OpaqueDepth",opaqueDepthView,nearest);
pass.setUniform("OpaqueColor",opaqueColorView!=null?opaqueColorView:scene,nearest);
// Never bind the receiver as a sampled texture while it is attached for depth writes.
pass.setUniform("ReceiverDepth",reflection?Minecraft.getInstance().gameRenderer.mainRenderTarget().getDepthTextureView():opaqueDepthView,nearest);
pass.setUniform("SceneColor",scene,nearest);
pass.setUniform("BlockAtlas",Minecraft.getInstance().getTextureManager().getTexture(TextureAtlas.LOCATION_BLOCKS).getTextureView(),nearest);
}
private static void draw(com.mojang.renderpearl.api.commands.RenderPass pass,List<PbrTerrain.Entry> entries,DynamicTexture atlas){
if(atlas==null||entries.isEmpty())return;
pass.setUniform("MaterialAtlas",atlas.getTextureView(),RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST));
var indices=RenderSystem.getSequentialBuffer(PrimitiveTopology.QUADS);
var camera=Minecraft.getInstance().gameRenderer.gameRenderState().levelRenderState.cameraRenderState;
for(var e:entries){
pass.setIndexBuffer(indices.getBuffer(),indices.type());
var p=e.origin();var offset=new Vector3f((float)(p.getX()-camera.pos.x),(float)(p.getY()-camera.pos.y),(float)(p.getZ()-camera.pos.z));
pass.setUniform("DynamicTransforms",RenderSystem.getDynamicUniforms().writeTransform(new Matrix4f(vp).translate(offset),new Vector4f(1,1,1,1),offset,new Matrix4f()));
pass.setVertexBuffer(0,e.vertices().slice());pass.drawIndexed(e.indices(),1,0,0,0);
}
}
public static void render(){
if(!ready||!VanillaLight.ssr()||VanillaLight.ssrIntensity()==0)return;
var main=Minecraft.getInstance().gameRenderer.mainRenderTarget();
var scene=ProceduralPbr.copyScene();
try(var pass=RenderSystem.getDevice().createCommandEncoder().createRenderPass(()->"Sanctuary pixel reflections",main.getColorTextureView(),Optional.empty(),receiverView,OptionalDouble.empty())){
pass.setPipeline(RenderSystem.getCompiledPipeline(REFLECT));bind(pass,scene,true);
draw(pass,solid,solidAtlas);draw(pass,transparent,transparentAtlas);
}
MaterialBloom.accumulate(scene);
applied=true;frames++;
}
public static void release(){
if(opaqueColorView!=null){opaqueColorView.close();opaqueColorView=null;}if(opaqueColor!=null){opaqueColor.close();opaqueColor=null;}
if(opaqueDepthView!=null){opaqueDepthView.close();opaqueDepthView=null;}if(opaqueDepth!=null){opaqueDepth.close();opaqueDepth=null;}if(receiverView!=null){receiverView.close();receiverView=null;}
if(receiver!=null){receiver.close();receiver=null;}if(uniform!=null){uniform.close();uniform=null;}
solid=transparent=List.of();solidAtlas=transparentAtlas=null;captured=ready=applied=false;
}
static long targetBytes(){return (long)width*height*((opaqueDepth!=null?4:0)+(receiver!=null?4:0)+(opaqueColor!=null?4:0));}
static GpuTexture landscape(){return opaqueColor;}
static boolean applied(){return applied;}static int frames(){return frames;}
static boolean allocated(){return opaqueColor!=null;}
}
@@ -33,19 +33,20 @@ public final class ProceduralPbr {
private static final RenderPipeline PIPELINE=RenderPipelines.register(RenderPipeline.builder()
.withLocation(Identifier.parse("sanctuary:pipeline/pbr")).withVertexShader(Identifier.parse("sanctuary:core/pbr"))
.withFragmentShader(Identifier.parse("sanctuary:core/pbr")).withBindGroupLayout(BindGroupLayouts.DYNAMIC_TRANSFORMS).withBindGroupLayout(SCENE)
.withVertexBinding(0,PbrTerrain.FORMAT).withPrimitiveTopology(PrimitiveTopology.QUADS).withCull(false)
.withVertexBinding(0,PbrTerrain.FORMAT).withPrimitiveTopology(PrimitiveTopology.QUADS).withCull(true)
.withColorTargetState(new ColorTargetState(Optional.empty(),GpuFormat.RGBA8_UNORM,ColorTargetState.WRITE_COLOR))
.withDepthStencilState(Optional.empty()).build());
.withDepthStencilState(new DepthStencilState(CompareOp.EQUAL,false)).build());
private static final PbrTerrain TERRAIN=new PbrTerrain();
private static final PbrTerrain TRANSPARENT=new PbrTerrain(true);
private static final Matrix4f projection=new Matrix4f();
private static TextureTarget source;
private static MappableRingBuffer scene;
private static boolean applied;private static int frames;
private ProceduralPbr(){}
public static void captureProjection(Matrix4f matrix){projection.set(matrix);}
public static void dirtyBlock(BlockPos pos){TERRAIN.dirty(pos);}
public static void dirtyChunk(int x,int z){TERRAIN.dirtyChunk(x,z);}
public static void dirtySection(int x,int y,int z){if(!fr.koka.sanctuary.client.DynamicLightClient.invalidatingSections())TERRAIN.dirtySection(x,y,z);}
public static void dirtyBlock(BlockPos pos){TERRAIN.dirty(pos);TRANSPARENT.dirty(pos);}
public static void dirtyChunk(int x,int z){TERRAIN.dirtyChunk(x,z);TRANSPARENT.dirtyChunk(x,z);}
public static void dirtySection(int x,int y,int z){if(!fr.koka.sanctuary.client.DynamicLightClient.invalidatingSections()){TERRAIN.dirtySection(x,y,z);TRANSPARENT.dirtySection(x,y,z);}}
public static void render(){
applied=false;var mc=Minecraft.getInstance();
if(!VanillaLight.active()||!VanillaLight.pbr()||VanillaLight.pbrIntensity()==0||mc.level==null){release();return;}
@@ -55,8 +56,12 @@ public final class ProceduralPbr {
if(source==null||source.width!=main.width||source.height!=main.height){if(source!=null)source.destroyBuffers();source=new TextureTarget("Sanctuary PBR source",main.width,main.height,GpuFormat.RGBA8_UNORM,null);}
// Padding supplies valid inactive PixelShadows and local-light uniform bindings.
if(scene==null)scene=new MappableRingBuffer(()->"Sanctuary PBR scene",GpuBuffer.USAGE_UNIFORM|GpuBuffer.USAGE_MAP_WRITE,176+ColoredDynamicLights.LIMIT*32);
var entries=TERRAIN.update();var atlas=TERRAIN.materials.atlas();
var solidEntries=TERRAIN.update();var atlas=TERRAIN.materials.atlas();
var transparentEntries=TRANSPARENT.update();var transparentAtlas=TRANSPARENT.materials.atlas();
var entries=new ArrayList<PbrTerrain.Entry>(solidEntries);entries.addAll(transparentEntries);
var vp=new Matrix4f(projection).mul(camera.viewRotationMatrix);var fog=camera.fogData;
PixelReflections.prepare(vp,solidEntries,atlas,transparentEntries,transparentAtlas);
if(PixelReflections.depth()==null)return;
var sun=PixelShadows.sunDirection(sky.sunAngle);boolean lunar=sun.y<=.08f;
if(lunar)sun=PixelShadows.sunDirection(sky.moonAngle);
float moon=sky.moonPhase==null?1:(float)(.5+.5*Math.cos(sky.moonPhase.index()*Math.PI/4));
@@ -65,7 +70,7 @@ public final class ProceduralPbr {
scene.rotate();try(var mapped=scene.currentBuffer().map(false,true)){
Std140Builder.intoBuffer(mapped.data()).putMat4f(new Matrix4f(vp).invert())
.putVec4(sun.x,sun.y,sun.z,strength)
.putVec4(VanillaLight.pbrIntensity()/100f,RenderSystem.getDevice().getDeviceInfo().isZZeroToOne()?1:0,shadow?1:0,32)
.putVec4(VanillaLight.pbrIntensity()/100f,RenderSystem.getDevice().getDeviceInfo().isZZeroToOne()?1:0,shadow?1:0,VanillaLight.pbrDistance())
.putVec4(fog.renderDistanceStart,fog.renderDistanceEnd,fog.environmentalStart,fog.environmentalEnd).putVec4(fog.color.w,ColoredLights.directionalReady()?1:0,0,0)
.putVec4(0,0,0,0).putVec4(0,0,0,0).putVec4(0,0,0,0).putVec4(0,0,0,0);
}
@@ -76,22 +81,33 @@ public final class ProceduralPbr {
var encoder=RenderSystem.getDevice().createCommandEncoder();
encoder.copyTextureToTexture(main.getColorTexture(),source.getColorTexture(),0,0,0,0,0,main.width,main.height);
var nearest=RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST);
if(atlas!=null&&!entries.isEmpty())try(var pass=encoder.createRenderPass(()->"Sanctuary PBR materials",main.getColorTextureView(),Optional.empty())){
if((atlas!=null||transparentAtlas!=null)&&!entries.isEmpty())try(var pass=encoder.createRenderPass(()->"Sanctuary PBR materials",main.getColorTextureView(),Optional.empty(),PixelReflections.depth(),OptionalDouble.empty())){
pass.setPipeline(RenderSystem.getCompiledPipeline(PIPELINE));pass.setUniform("PbrScene",scene.currentBuffer());
pass.setUniform("PixelShadows",shadow?PixelShadows.materialUniforms():scene.currentBuffer());
pass.setUniform("SunDepth",shadow?PixelShadows.materialDepth():main.getDepthTextureView(),nearest);
pass.setUniform("WorldDepth",main.getDepthTextureView(),nearest);pass.setUniform("SceneColor",source.getColorTextureView(),nearest);
pass.setUniform("BlockAtlas",mc.getTextureManager().getTexture(TextureAtlas.LOCATION_BLOCKS).getTextureView(),nearest);
pass.setUniform("MaterialAtlas",atlas.getTextureView(),nearest);
pass.setUniform("MaterialAtlas",(atlas!=null?atlas:transparentAtlas).getTextureView(),nearest);
if(ColoredLights.directionalReady())ColoredLights.bindDirections(pass);
else{pass.setUniform("ColoredLightScene",scene.currentBuffer());pass.setUniform("LightDirection",source.getColorTextureView(),nearest);pass.setUniform("PreviousDirection",source.getColorTextureView(),nearest);}
pass.setIndexBuffer(indices.getBuffer(),indices.type());
for(int i=0;i<entries.size();i++){var entry=entries.get(i);pass.setUniform("DynamicTransforms",transforms.get(i));pass.setVertexBuffer(0,entry.vertices().slice());pass.drawIndexed(entry.indices(),1,0,0,0);}
for(int i=0;i<entries.size();i++){var entry=entries.get(i);
pass.setUniform("MaterialAtlas",(i<solidEntries.size()?atlas:transparentAtlas).getTextureView(),nearest);pass.setUniform("DynamicTransforms",transforms.get(i));pass.setVertexBuffer(0,entry.vertices().slice());pass.drawIndexed(entry.indices(),1,0,0,0);}
}
MaterialBloom.accumulate(source.getColorTextureView());
applied=true;frames++;
}
public static void release(){TERRAIN.close();if(source!=null){source.destroyBuffers();source=null;}if(scene!=null){scene.close();scene=null;}applied=false;}
static boolean applied(){return applied;}static boolean ready(){return TERRAIN.settled();}
static com.mojang.renderpearl.api.textures.GpuTextureView copyScene(){
var main=Minecraft.getInstance().gameRenderer.mainRenderTarget();
RenderSystem.getDevice().createCommandEncoder().copyTextureToTexture(main.getColorTexture(),source.getColorTexture(),0,0,0,0,0,main.width,main.height);
return source.getColorTextureView();
}
public static void release(){MaterialBloom.release();TERRAIN.close();TRANSPARENT.close();PixelReflections.release();if(source!=null){source.destroyBuffers();source=null;}if(scene!=null){scene.close();scene=null;}applied=false;}
static long solidBytes(){return TERRAIN.allocatedBytes();}
static long transparentBytes(){return TRANSPARENT.allocatedBytes();}
static int waterUpdates(){return TRANSPARENT.materials.animationUpdates();}
static int waterAnalyses(){return TRANSPARENT.materials.analyses();}
static boolean applied(){return applied;}static boolean ready(){return TERRAIN.settled()&&TRANSPARENT.settled();}
static boolean allocated(){return source!=null;}static int renderedFrames(){return frames;}
static int terrainEntries(){return TERRAIN.entryCount();}
static int terrainBuilds(){return TERRAIN.completed();}
@@ -14,6 +14,12 @@ 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<Integer>("sanctuary.shaders.water_blend",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.water_blend_help")),(caption,value)->Component.translatable("sanctuary.shaders.water_blend_value",value*2+1,value*2+1),
new OptionInstance.Enum<>(List.of(0,1,2,4,8),Codec.INT),VanillaLight.waterBlendRadius(),VanillaLight::setWaterBlendRadius));
list.addBig(new OptionInstance<Integer>("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));
@@ -23,6 +29,12 @@ public final class ShaderOptionsScreen extends OptionsSubScreen {
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.pbr",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.pbr_help")),VanillaLight.pbr(),VanillaLight::setPbr));
slider("pbr_intensity",VanillaLight.pbrIntensity(),VanillaLight::setPbrIntensity);
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.ssr",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.ssr_help")),VanillaLight.ssr(),VanillaLight::setSsr));
slider("ssr_intensity",VanillaLight.ssrIntensity(),VanillaLight::setSsrIntensity);
list.addBig(new OptionInstance<Integer>("sanctuary.shaders.pbr_distance",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.pbr_distance_help")),(caption,value)->Component.translatable("sanctuary.shaders.pbr_distance_value",value),
new OptionInstance.Enum<>(List.of(16,32,64,128),Codec.INT),VanillaLight.pbrDistance(),VanillaLight::setPbrDistance));
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.colored_lights",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.colored_lights_help")),VanillaLight.coloredLights(),VanillaLight::setColoredLights));
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.warm_torches",OptionInstance.cachedConstantTooltip(
@@ -16,6 +16,11 @@ public final class VanillaLight {
private VanillaLight() { }
private static final class Preferences {
boolean enabled=true;
Boolean naturalWater; // Legacy OFF is an explicit opt-out in both world types.
Boolean sanctuaryNaturalWater;
Boolean ordinaryNaturalWater;
int waterBlendRadius=4;
int waterIntensity=100;
int saturation=142;
boolean pixelShadows=true;
boolean bloom=true;
@@ -25,6 +30,9 @@ public final class VanillaLight {
int lensFlareIntensity=30;
boolean pbr=true;
int pbrIntensity=80;
int pbrDistance=32;
boolean ssr=false;
int ssrIntensity=20;
boolean ssgi=true;
boolean coloredLights=true;
boolean warmTorches=false;
@@ -47,8 +55,15 @@ public final class VanillaLight {
try{if(Files.isRegularFile(path()))preferences=new GsonBuilder().create().fromJson(Files.readString(path()),Preferences.class);}
catch(Exception e){SanctuaryMod.LOGGER.warn("Cannot read Sanctuary shader preference",e);}
if(preferences==null)preferences=new Preferences();
if(Boolean.FALSE.equals(preferences.naturalWater)){
if(preferences.sanctuaryNaturalWater==null)preferences.sanctuaryNaturalWater=false;
if(preferences.ordinaryNaturalWater==null)preferences.ordinaryNaturalWater=false;
}
preferences.naturalWater=null;
preferences.sunshaftIntensity=Math.clamp(preferences.sunshaftIntensity,0,100);
preferences.lensFlareIntensity=Math.clamp(preferences.lensFlareIntensity,0,100);
preferences.ssrIntensity=Math.clamp(preferences.ssrIntensity,0,100);
preferences.pbrDistance=normalizePbrDistance(preferences.pbrDistance);
preferences.pbrIntensity=Math.clamp(preferences.pbrIntensity,0,100);
preferences.coloredLightIntensity=Math.clamp(preferences.coloredLightIntensity,0,100);
preferences.coloredLightDistance=normalizeColoredLightDistance(preferences.coloredLightDistance);
@@ -58,6 +73,8 @@ 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.waterBlendRadius=normalizeWaterBlendRadius(preferences.waterBlendRadius);
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 +83,14 @@ 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(){return naturalWater(NaturalWater.sanctuaryWorld());}
static boolean naturalWater(boolean sanctuary){enabled();Boolean value=sanctuary?preferences.sanctuaryNaturalWater:preferences.ordinaryNaturalWater;return value!=null?value:sanctuary;}
public static int waterBlendRadius(){enabled();return preferences.waterBlendRadius;}
public static void setWaterBlendRadius(int value){enabled();preferences.waterBlendRadius=normalizeWaterBlendRadius(value);save();}
static int normalizeWaterBlendRadius(int value){return value<1?0:value<2?1:value<3?2:value<6?4:8;}
public static int waterIntensity(){enabled();return preferences.waterIntensity;}
public static void setNaturalWater(boolean value){enabled();if(NaturalWater.sanctuaryWorld())preferences.sanctuaryNaturalWater=value;else preferences.ordinaryNaturalWater=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;}
@@ -81,6 +106,13 @@ public final class VanillaLight {
public static int lensFlareIntensity(){enabled();return preferences.lensFlareIntensity;}
public static void setLensFlare(boolean value){enabled();preferences.lensFlare=value;save();}
public static void setLensFlareIntensity(int value){enabled();preferences.lensFlareIntensity=Math.clamp(value,0,100);save();}
public static boolean ssr(){enabled();return preferences.ssr;}
public static int ssrIntensity(){enabled();return preferences.ssrIntensity;}
public static void setSsr(boolean value){enabled();preferences.ssr=value;save();}
public static void setSsrIntensity(int value){enabled();preferences.ssrIntensity=Math.clamp(value,0,100);save();}
public static int pbrDistance(){enabled();return preferences.pbrDistance;}
public static void setPbrDistance(int value){enabled();preferences.pbrDistance=normalizePbrDistance(value);save();}
static int normalizePbrDistance(int value){return value<24?16:value<48?32:value<96?64:128;}
public static boolean pbr(){enabled();return preferences.pbr;}
public static int pbrIntensity(){enabled();return preferences.pbrIntensity;}
public static void setPbr(boolean value){enabled();preferences.pbr=value;save();}
@@ -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<Void> original){
var previous=sanctuary$snapshot;
try{
sanctuary$corners=null;sanctuary$snapshot=null;sanctuary$base=-1;
if(fluid.is(FluidTags.WATER)&&region 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<Integer> 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<Void> 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);
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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="<init>",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;}
}
@@ -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<BlockState> sanctuary$states();
}
@@ -0,0 +1,10 @@
package fr.koka.sanctuary.mixin.client;
import fr.koka.sanctuary.client.shader.PbrAnimation;
import net.minecraft.client.renderer.texture.SpriteContents;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;
@Mixin(SpriteContents.AnimationState.class)
public interface PbrAnimationClockMixin extends PbrAnimation.Clock {
@Accessor("frame") int sanctuary$frame();
@Accessor("subFrame") int sanctuary$subFrame();
}
@@ -0,0 +1,14 @@
package fr.koka.sanctuary.mixin.client;
import fr.koka.sanctuary.client.shader.PbrAnimation;
import net.minecraft.client.renderer.texture.SpriteContents;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
@Mixin(SpriteContents.class)
abstract class PbrAnimationSpriteMixin {
@Inject(method="createAnimationState",at=@At("RETURN"))
private void sanctuary$clock(CallbackInfoReturnable<SpriteContents.AnimationState> ci){
PbrAnimation.register((SpriteContents)(Object)this,ci.getReturnValue());
}
}
@@ -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"))
@@ -31,7 +31,7 @@ abstract class PixelShadowRendererMixin {
return original.call(buffer, matrix);
}
@Inject(method="renderLevel", at=@At(value="INVOKE", target="Lnet/minecraft/client/renderer/GameRenderer;render3dHud(Lnet/minecraft/client/renderer/state/level/CameraRenderState;Lnet/minecraft/client/renderer/state/level/PlayerRenderState;Lnet/minecraft/client/renderer/state/OptionsRenderState;Z)V"))
private void sanctuary$shadows(CallbackInfo ci) { ColoredLights.prepare(); PixelShadows.render(); ProceduralPbr.render(); ColoredLights.render(); ScreenSpaceGI.render(); EdgeHighlights.render(); Bloom.render(); Sunshafts.render(); }
private void sanctuary$shadows(CallbackInfo ci) { ColoredLights.prepare(); PixelShadows.render(); ProceduralPbr.render(); ColoredLights.render(); ScreenSpaceGI.render(); fr.koka.sanctuary.client.shader.PixelReflections.render(); EdgeHighlights.render(); Bloom.render(); Sunshafts.render(); }
@Inject(method={"close", "setLevel"}, at=@At("HEAD"))
private void sanctuary$close(CallbackInfo ci) { PixelShadows.release(); ProceduralPbr.release(); EdgeHighlights.release(); Bloom.release(); ColoredLights.release(); ScreenSpaceGI.release(); Sunshafts.release(); }
}
@@ -0,0 +1,41 @@
package fr.koka.sanctuary.mixin.client;
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.renderpearl.api.commands.RenderPass;
import fr.koka.sanctuary.client.shader.PixelReflections;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.LevelRenderer;
import net.minecraft.client.renderer.chunk.ChunkSectionsToRender;
import net.minecraft.client.renderer.feature.FeatureRenderDispatcher;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import java.util.Optional;
import java.util.OptionalDouble;
/** Splits only the classic pass; copying an active Vulkan attachment is not legal. */
@Mixin(LevelRenderer.class)
abstract class ReflectionSceneMixin {
@Unique private java.util.function.Consumer<RenderPass> sanctuary$transparency;
@Inject(method="render",at=@At("HEAD"))
private void sanctuary$begin(CallbackInfo ci){sanctuary$transparency=null;PixelReflections.beginFrame();}
@WrapOperation(method="lambda$addMainPass$0",at=@At(value="INVOKE",target="Lnet/minecraft/client/renderer/LevelRenderer;executeClassicTransparency(Lnet/minecraft/client/renderer/chunk/ChunkSectionsToRender;Lnet/minecraft/client/renderer/feature/FeatureRenderDispatcher$PreparedFrame;Lcom/mojang/renderpearl/api/commands/RenderPass;)V"))
private void sanctuary$defer(LevelRenderer self,ChunkSectionsToRender chunks,FeatureRenderDispatcher.PreparedFrame features,RenderPass pass,Operation<Void> original){
if(!PixelReflections.eligible()){original.call(self,chunks,features,pass);return;}
sanctuary$transparency=next->original.call(self,chunks,features,next);
}
@Inject(method="executeOit",at=@At("HEAD"))
private void sanctuary$oit(CallbackInfo ci){PixelReflections.captureOpaque();}
@Inject(method="executeOutline",at=@At("HEAD"))
private void sanctuary$classic(CallbackInfo ci){
var action=sanctuary$transparency;sanctuary$transparency=null;if(action==null)return;
PixelReflections.captureOpaque();var main=Minecraft.getInstance().gameRenderer.mainRenderTarget();
try(var pass=RenderSystem.getDevice().createCommandEncoder().createRenderPass(()->"Sanctuary native transparency",main.getColorTextureView(),Optional.empty(),main.getDepthTextureView(),OptionalDouble.empty())){
RenderSystem.bindDefaultUniforms(pass);action.accept(pass);
}
}
}
@@ -0,0 +1,32 @@
package fr.koka.sanctuary.water;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.expansion.ExpansionChunkGenerator;
import net.fabricmc.fabric.api.networking.v1.*;
import net.fabricmc.fabric.api.entity.event.v1.ServerEntityLevelChangeEvents;
import net.fabricmc.fabric.api.entity.event.v1.ServerPlayerEvents;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.server.level.ServerPlayer;
/** Read-only dimension context; no persistent world or generation changes. */
public final class WaterWorldSettings {
private WaterWorldSettings() {}
public record Context(boolean sanctuary) implements CustomPacketPayload {
public static final Type<Context> TYPE=new Type<>(SanctuaryMod.id("water_world_v1"));
public static final StreamCodec<RegistryFriendlyByteBuf,Context> CODEC=CustomPacketPayload.codec(
(p,b)->b.writeBoolean(p.sanctuary),b->new Context(b.readBoolean()));
public Type<Context> type(){return TYPE;}
}
public static void register(){
PayloadTypeRegistry.clientboundPlay().register(Context.TYPE,Context.CODEC);
ServerPlayConnectionEvents.JOIN.register((handler,sender,server)->send(handler.player));
ServerEntityLevelChangeEvents.AFTER_PLAYER_CHANGE_LEVEL.register((player,origin,destination)->send(player));
ServerPlayerEvents.AFTER_RESPAWN.register((oldPlayer,player,alive)->send(player));
}
private static void send(ServerPlayer player){
if(ServerPlayNetworking.canSend(player,Context.TYPE))
ServerPlayNetworking.send(player,new Context(player.level().getChunkSource().getGenerator() instanceof ExpansionChunkGenerator));
}
}
@@ -21,6 +21,9 @@ import net.minecraft.world.level.levelgen.Heightmap;
/** Melt only known weather deposits, using the existing precipitation column sampler. */
public final class SeasonalSnowMelt {
/** One real day lasts 72 native Minecraft days. Keep the native sampler, slow only its melt chance. */
public static final int REALTIME_SCALE = 72;
public static final GameRule<Boolean> ENABLED = GameRuleBuilder.forBoolean(true)
.category(GameRuleCategory.UPDATES).buildAndRegister(SanctuaryMod.id("seasonal_snow_melt"));
@@ -77,12 +80,7 @@ public final class SeasonalSnowMelt {
var chunk = level.getChunkAt(column);
var entries = chunk.getAttached(NATURAL);
if (entries == null || entries.isEmpty()) return;
int chance = switch (WeatherService.season(level.getServer())) {
case WINTER -> 0;
case SPRING -> 2;
case SUMMER -> 1;
case AUTUMN -> 4;
};
int chance = chance(WeatherService.season(level.getServer()));
if (chance == 0 || level.getRandom().nextInt(chance) != 0) return;
var pos = level.getHeightmapPos(Heightmap.Types.MOTION_BLOCKING, column);
// Thin layers are above the heightmap; a full snow block is just below it.
@@ -103,5 +101,15 @@ public final class SeasonalSnowMelt {
if (level.setBlockAndUpdate(pos, after)) remember(level, pos, entry.base, after);
}
public static int chance(SeasonalWeather.Season season) {
int seasonal = switch (season) {
case WINTER -> 0;
case SPRING -> 2;
case SUMMER -> 1;
case AUTUMN -> 4;
};
return seasonal == 0 ? 0 : seasonal * REALTIME_SCALE;
}
private static String key(BlockPos pos) { return Long.toString(pos.asLong()); }
}
@@ -16,8 +16,11 @@ import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.gamerules.GameRule;
import net.minecraft.world.level.gamerules.GameRuleCategory;
/** One real layer per native precipitation tick; no additional chunk or entity scan. */
/** Weather snow accumulation, using the native precipitation sampler without chunk scans. */
public final class SnowAccumulation {
/** Snowfall should remain visible during a session, but no longer stack instantly. */
public static final int REALTIME_SCALE = 12;
public static final GameRule<Integer> MAX_HEIGHT = GameRuleBuilder.forInteger(2)
.range(-1, 128).category(GameRuleCategory.UPDATES)
.buildAndRegister(SanctuaryMod.id("snow_accumulation_height"));
@@ -38,7 +41,7 @@ public final class SnowAccumulation {
/** Return whether vanilla should perform its own deposit. */
public static boolean replaceNative(ServerLevel level, BlockPos pos, boolean canSnow) {
if (!canSnow || !ENABLED.contains(level.getServer())) return canSnow;
if (canSnow) deposit(level, pos);
if (level.getRandom().nextInt(REALTIME_SCALE) == 0) deposit(level, pos);
return false;
}
@@ -1,4 +1,9 @@
{
"sanctuary.shaders.ssr_intensity_value": "Reflection strength: %s%%",
"sanctuary.shaders.ssr_intensity_help": "Strength of visible landscape reflections on solids and water. Default: 20%.",
"sanctuary.shaders.ssr": "Landscape reflections — experimental",
"sanctuary.shaders.ssr_help": "Colours material texels with the visible landscape. Requires PBR; off-screen objects cannot be reflected.",
"sanctuary.shaders.ssr_intensity": "Reflection strength",
"sanctuary.graphics.default_vulkan": "Vulkan (default)",
"sanctuary.statuary.clay_ready": "Clay: original model colors.",
"sanctuary.weather.selected": "Real Time weather: %s for %s (%s). The daily forecast resumes at midnight.",
@@ -2297,9 +2302,9 @@
"sanctuary.shaders.ssgi_intensity_value": "SSGI: %s%%",
"sanctuary.shaders.pbr": "PBR · material relief",
"sanctuary.shaders.pbr_help": "Experimental: procedural block normals and roughness, sun, moon and local light highlights. Enable Colored lights for local direction. Range 32 blocks. ON at 80% by default.",
"sanctuary.shaders.pbr_intensity": "PBR strength",
"sanctuary.shaders.pbr_intensity_help": "Material relief and highlights. Zero stops rendering and frees the cache. Original textures remain unchanged.",
"sanctuary.shaders.pbr_intensity_value": "PBR: %s%%",
"sanctuary.shaders.pbr_intensity": "PBR intensity",
"sanctuary.shaders.pbr_intensity_help": "Material relief and highlights, including water. Default: 80%.",
"sanctuary.shaders.pbr_intensity_value": "PBR intensity: %s %%",
"sanctuary.shaders.lens_flare": "Lens flare · soft rectangles",
"sanctuary.shaders.lens_flare_help": "Soft rectangular sun and moon reflections. Obstacles hide the sources. Independent of the bloom halo.",
"sanctuary.shaders.lens_flare_intensity": "Lens flare intensity",
@@ -2319,5 +2324,16 @@
"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": "Bed-dependent tint, transparency and fog. Defaults OFF in ordinary worlds and ON on Sanctuary islands. Preferences are stored separately for each world type.",
"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%%",
"sanctuary.shaders.pbr_distance": "PBR and SSR distance",
"sanctuary.shaders.pbr_distance_value": "PBR and SSR distance: %s blocks",
"sanctuary.shaders.pbr_distance_help": "Shared range for relief and reflecting surfaces. 16, 32, 64 or 128 blocks. Longer distances cost more; mesh memory is bounded.",
"sanctuary.shaders.water_blend": "Water blending",
"sanctuary.shaders.water_blend_value": "Water blending: %s × %s blocks",
"sanctuary.shaders.water_blend_help": "Transition width between bed-dependent water colours: 1, 3, 5, 9 or 17 blocks. Default: 9 × 9."
}
@@ -1,4 +1,9 @@
{
"sanctuary.shaders.ssr_intensity_value": "Intensité des reflets : %s %%",
"sanctuary.shaders.ssr_intensity_help": "Intensité des reflets du paysage visible, sur les solides et l’eau. Défaut : 20 %.",
"sanctuary.shaders.ssr": "Reflets du paysage — expérimental",
"sanctuary.shaders.ssr_help": "Colore les pixels de matière avec le paysage visible. PBR requis ; les objets hors écran ne sont pas réfléchis.",
"sanctuary.shaders.ssr_intensity": "Intensité des reflets",
"sanctuary.graphics.default_vulkan": "Vulkan (par défaut)",
"sanctuary.statuary.clay_ready": "Argile : couleurs originales du modèle.",
"sanctuary.weather.selected": "Météo Real Time : %s pour la journée du %s (%s). Le tirage quotidien reprend à minuit.",
@@ -2297,9 +2302,9 @@
"sanctuary.shaders.ssgi_intensity_value": "SSGI : %s %%",
"sanctuary.shaders.pbr": "PBR · relief des matériaux",
"sanctuary.shaders.pbr_help": "Expérimental : normales procédurales et rugosité des blocs, reflets du soleil, de la lune et des lumières locales. Activer Lumières colorées pour la direction locale. Portée 32 blocs. Activé par défaut à 80 %.",
"sanctuary.shaders.pbr_intensity": "Intensité du PBR",
"sanctuary.shaders.pbr_intensity_help": "Relief et reflets des matériaux. Zéro coupe le calcul et libère le cache. Ne modifie pas les textures originales.",
"sanctuary.shaders.pbr_intensity_value": "PBR : %s %%",
"sanctuary.shaders.pbr_intensity": "Intensité PBR",
"sanctuary.shaders.pbr_intensity_help": "Relief et éclats des matières, eau comprise. Défaut : 80 %.",
"sanctuary.shaders.pbr_intensity_value": "Intensité PBR : %s %%",
"sanctuary.shaders.lens_flare": "Lens flare · rectangles doux",
"sanctuary.shaders.lens_flare_help": "Reflets rectangulaires du soleil et de la lune, aux bords dégradés. Les obstacles masquent les sources. Indépendant du halo de bloom.",
"sanctuary.shaders.lens_flare_intensity": "Intensité du lens flare",
@@ -2319,5 +2324,16 @@
"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": "Teintes, transparence et brume selon le fond. OFF par défaut dans les mondes ordinaires, ON sur les îles Sanctuary. Choix mémorisé séparément pour chaque type de monde.",
"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%%",
"sanctuary.shaders.pbr_distance": "Distance PBR et SSR",
"sanctuary.shaders.pbr_distance_value": "Distance PBR et SSR : %s blocs",
"sanctuary.shaders.pbr_distance_help": "Portée commune du relief et des surfaces réfléchissantes. 16, 32, 64 ou 128 blocs. Les distances élevées coûtent davantage ; budget de maillage limité.",
"sanctuary.shaders.water_blend": "Mélange des eaux",
"sanctuary.shaders.water_blend_value": "Mélange des eaux : %s × %s blocs",
"sanctuary.shaders.water_blend_help": "Étendue de la transition des teintes selon le fond : 1, 3, 5, 9 ou 17 blocs. Défaut : 9 × 9."
}
@@ -0,0 +1,6 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
uniform sampler2D InputTexture;
layout(location=0) in vec2 texCoord;
layout(location=0) out vec4 fragColor;
void main(){fragColor=vec4(texture(InputTexture,texCoord).rgb,0);}
@@ -0,0 +1,16 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
uniform sampler2D Before;
uniform sampler2D After;
layout(location=0) in vec2 texCoord;
layout(location=0) out vec4 fragColor;
void main(){
vec2 pixel=1.0/vec2(textureSize(After,0));vec3 glow=vec3(0);
for(int y=0;y<8;y++)for(int x=0;x<8;x++){
vec2 uv=texCoord+(vec2(x,y)-3.5)*pixel;
vec3 a=texture(After,uv).rgb,b=texture(Before,uv).rgb;
// A restrained halo for material light, stronger for near-white solar glints.
glow+=max(a*a-b*b,vec3(0))*(.12+.28*smoothstep(.75,1.0,max(a.r,max(a.g,a.b))));
}
fragColor=vec4(glow/64.0,0);
}
@@ -89,13 +89,13 @@ vec3 localShine(vec3 n,vec3 v,vec3 l,vec4 material,vec3 albedo){
vec3 halfVector=l+v;float size=dot(halfVector,halfVector);if(size<.00001)return vec3(0);
vec3 h=halfVector*inversesqrt(size);
float nl=clamp(dot(n,l),0.0,1.0),nv=clamp(dot(n,v),.001,1.0),nh=clamp(dot(n,h),0.0,1.0),vh=clamp(dot(v,h),0.0,1.0);
float roughness=max(material.b,.25),alpha=roughness*roughness,a2=alpha*alpha;
float denom=nh*nh*(a2-1.0)+1.0,distribution=a2/max(3.14159265*denom*denom,.00001);
float roughness=max(material.b,.16),alpha=roughness*roughness,a2=alpha*alpha;
float denom=nh*nh*(a2-1.0)+1.0,distribution=a2/max(3.14159265*denom*denom,.00000001);
float k=(roughness+1.0)*(roughness+1.0)/8.0;
float geometry=nv/(nv*(1.0-k)+k)*nl/max(nl*(1.0-k)+k,.00001);
// Explicit integer power stays defined even at round-off limits on OpenGL drivers.
float grazing=1.0-vh,grazing2=grazing*grazing;
vec3 f0=mix(vec3(.04),albedo,material.a),fresnel=f0+(1.0-f0)*(grazing2*grazing2*grazing);
vec3 f0=mix(vec3(maskUv.y>=4.0?.02:.04),albedo,material.a),fresnel=f0+(1.0-f0)*(grazing2*grazing2*grazing);
return distribution*geometry*fresnel/max(4.0*nv*nl,.0001)*nl;
}
void main(){
@@ -104,23 +104,33 @@ void main(){
vec2 tx=dFdx(blockUv),ty=dFdy(blockUv);
float det=tx.x*ty.y-tx.y*ty.x;
vec3 baseNormal=normalize(cross(dx,dy));if(dot(baseNormal,-relativePosition)<0.0)baseNormal=-baseNormal;
vec2 uv=gl_FragCoord.xy/vec2(textureSize(WorldDepth,0));float depth=texelFetch(WorldDepth,ivec2(gl_FragCoord.xy),0).r;
vec4 projected=PbrInverseVP*vec4(uv*2.0-1.0,PbrParams.y>0.5?depth:depth*2.0-1.0,1.0);
vec3 visible=projected.xyz/projected.w;
if(depth<=0.0||abs(length(visible)-length(relativePosition))>0.035||length(relativePosition)>=PbrParams.w||abs(det)<1e-12)discard;
// Exact receiver depth is tested by the GPU; final scene depth also masks entities.
float visibleDepth=texelFetch(WorldDepth,ivec2(gl_FragCoord.xy),0).r;
vec2 screenUv=gl_FragCoord.xy/vec2(textureSize(WorldDepth,0));
vec4 visiblePoint=PbrInverseVP*vec4(screenUv*2.0-1.0,PbrParams.y>.5?visibleDepth:visibleDepth*2.0-1.0,1);
// Native terrain and material matrices have different evaluation order. Use a
// bounded 5 mm tolerance only for native occlusion; receiver selection stays exact.
if((visibleDepth>0.0&&length(visiblePoint.xyz/visiblePoint.w)+.005<length(relativePosition))||length(relativePosition)>=PbrParams.w||abs(det)<1e-12)discard;
// Keep atlas derivatives valid after per-fragment depth/alpha rejection.
vec4 texel=textureGrad(BlockAtlas,blockUv,tx,ty);if(texel.a<0.99)discard;
float family=floor(maskUv.y*.5);
vec4 texel=textureGrad(BlockAtlas,blockUv,tx,ty);if(family<.5&&texel.a<0.99)discard;
vec3 tangent=normalize((dx*ty.y-dy*tx.y)/det),bitangent=normalize((dy*tx.x-dx*ty.x)/det);
vec4 material=textureLod(MaterialAtlas,maskUv,0.0);
vec4 material=textureLod(MaterialAtlas,vec2(maskUv.x,maskUv.y-family*2.0),0.0);
// RG stores signed height derivatives; B roughness, A metalness, not transparency.
vec2 slope=(material.rg*255.0-128.0)/127.0*PbrParams.x*2.0;
float intensity=PbrParams.x;
vec2 slope=(material.rg*255.0-128.0)/127.0*intensity*(family>1.5?.24:2.0);
vec3 n=normalize(baseNormal+tangent*slope.x+bitangent*slope.y);
// Reject degenerate/interpolated geometry without replacing the native scene with NaNs.
if(any(isnan(n))||any(isinf(n))||dot(relativePosition,relativePosition)<.000001)discard;
vec3 l=normalize(PbrSun.xyz),v=normalize(-relativePosition);
float nl=clamp(dot(n,l),0.0,1.0);
vec3 albedo=texel.rgb*tint.rgb;albedo*=albedo;
vec3 specular=localShine(n,v,l,material,albedo);
// A broad solar lobe gives water an extended glitter path at grazing angles.
// Local lamps retain the material's original narrow lobe.
vec4 solarMaterial=material;
solarMaterial.b=max(material.b,family>1.5?.34:.24);
vec3 specular=localShine(n,v,l,solarMaterial,albedo);
float solarRadiance=family>1.5?20.0:4.0;
float fog=max(clamp((max(length(relativePosition.xz),abs(relativePosition.y))-PbrFog.x)/max(PbrFog.y-PbrFog.x,.001),0.0,1.0),
clamp((length(relativePosition)-PbrFog.z)/max(PbrFog.w-PbrFog.z,.001),0.0,1.0));
float amount=PbrSun.w*pow(tint.a,8.0)*visibility(relativePosition,baseNormal)*(1.0-fog*PbrEnvironment.x)
@@ -130,13 +140,14 @@ void main(){
if(amount<=0.0001&&localAmount<=0.0001)discard;
vec3 scene=texelFetch(SceneColor,ivec2(gl_FragCoord.xy),0).rgb;vec3 linear=scene*scene;
// Native diffuse light already exists. Apply only the perturbed-normal difference, then add GGX.
float relief=(nl-max(dot(baseNormal,l),0.0))*.7;
float relief=family<.5?(nl-max(dot(baseNormal,l),0.0))*.7:0.0;
vec3 lit=clamp(linear*(1.0+relief*amount),0.0,1.0);
if(localAmount>.0001){
float localRelief=(max(dot(n,arrival.xyz),0.0)-max(dot(baseNormal,arrival.xyz),0.0))*.7;
float localRelief=family<.5?(max(dot(n,arrival.xyz),0.0)-max(dot(baseNormal,arrival.xyz),0.0))*.7:0.0;
lit=clamp(lit*(1.0+localRelief*localAmount),0.0,1.0);
}
vec3 shine=min((specular*amount+localShine(n,v,arrival.xyz,material,albedo)*localAmount)*PbrParams.x*3.0,vec3(0.5));
// Preserve bright solar highlights instead of capping them at a medium grey.
vec3 shine=min((specular*amount*solarRadiance+localShine(n,v,arrival.xyz,material,albedo)*localAmount)*intensity*5.0,vec3(8.0));
lit+=(1.0-lit)*(1.0-exp(-shine));
if(any(isnan(lit))||any(isinf(lit)))discard;
fragColor=vec4(sqrt(clamp(lit,0.0,1.0)),1.0);
@@ -1,6 +1,8 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
#include <minecraft:dynamictransforms.glsl>
// Receiver and colour passes must produce bit-identical depth on every backend.
invariant gl_Position;
layout(location=0) in vec3 Position;
layout(location=1) in vec4 Color;
layout(location=2) in vec2 UV0;
@@ -0,0 +1,17 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
uniform sampler2D OpaqueDepth;
layout(std140) uniform ReflectionScene { mat4 ReflectionVP; mat4 ReflectionInverseVP; vec4 ReflectionParams; vec4 ReflectionLimits; };
uniform sampler2D BlockAtlas;
layout(location=0) in vec3 relativePosition;
layout(location=1) in vec2 blockUv;
layout(location=2) in vec2 maskUv;
layout(location=3) in vec4 tint;
void main(){
vec2 dx=dFdx(blockUv),dy=dFdy(blockUv);
if(length(relativePosition)>=ReflectionParams.w)discard;
// Minecraft's world projection uses reverse depth: zero is the far plane.
float opaque=texelFetch(OpaqueDepth,ivec2(gl_FragCoord.xy),0).r;
if(gl_FragCoord.z+0.0000001<opaque)discard;
if(maskUv.y<2.0&&textureGrad(BlockAtlas,blockUv,dx,dy).a<.99)discard;
}
@@ -0,0 +1,131 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
uniform sampler2D OpaqueDepth;
uniform sampler2D OpaqueColor;
uniform sampler2D ReceiverDepth;
uniform sampler2D SceneColor;
uniform sampler2D BlockAtlas;
uniform sampler2D MaterialAtlas;
layout(std140) uniform ReflectionScene {
mat4 ReflectionVP;
mat4 ReflectionInverseVP;
vec4 ReflectionParams; // zero-to-one, strength, normal strength, receiver distance
vec4 ReflectionLimits; // ray distance, steps, reserved
};
layout(location=0) in vec3 relativePosition;
layout(location=1) in vec2 blockUv;
layout(location=2) in vec2 maskUv;
layout(location=3) in vec4 tint;
layout(location=0) out vec4 fragColor;
vec3 reconstruct(vec2 uv,float d){
vec4 p=ReflectionInverseVP*vec4(uv*2.0-1.0,ReflectionParams.x>.5?d:d*2.0-1.0,1);
return p.xyz/p.w;
}
bool sampleRay(vec3 origin,vec3 direction,float t,out vec2 uv,out float gap){
vec3 p=origin+direction*t;vec4 clip=ReflectionVP*vec4(p,1);
if(clip.w<=.001)return false;
uv=clip.xy/clip.w*.5+.5;
if(any(lessThanEqual(uv,vec2(0)))||any(greaterThanEqual(uv,vec2(1))))return false;
float depth=textureLod(OpaqueDepth,uv,0).r;
if(depth<=0.0){gap=-1e6;return true;}
float rayDepth=ReflectionParams.x>.5?clip.z/clip.w:clip.z/clip.w*.5+.5;
gap=depth-rayDepth;return true;
}
vec4 trace(vec3 p,vec3 n,vec3 geometricNormal){
vec3 direction=reflect(normalize(p),n),origin=p+n*.035;
// A perturbed normal must not send the ray back through its own geometric face.
if(dot(direction,geometricNormal)<=.0001)return vec4(0);
vec4 a=ReflectionVP*vec4(origin,1),b=ReflectionVP*vec4(origin+direction*ReflectionLimits.x,1);
if(a.w<=.001)return vec4(0);
float distance=ReflectionLimits.x;
if(b.w<=.01){distance*=clamp((a.w-.01)/(a.w-b.w),0.0,1.0);b=ReflectionVP*vec4(origin+direction*distance,1);}
if(distance<.1)return vec4(0);
// Perspective-correct interpolation spaces the bounded march in screen coordinates.
vec2 start=a.xy/a.w*.5+.5,end=b.xy/b.w*.5+.5,delta=end-start;
float screenLimit=1.0;
if(delta.x>0.0)screenLimit=min(screenLimit,(.9999-start.x)/delta.x);
if(delta.x<0.0)screenLimit=min(screenLimit,(.0001-start.x)/delta.x);
if(delta.y>0.0)screenLimit=min(screenLimit,(.9999-start.y)/delta.y);
if(delta.y<0.0)screenLimit=min(screenLimit,(.0001-start.y)/delta.y);
if(screenLimit<=0.0)return vec4(0);
// Adaptive coarse march: large landscape silhouettes need fewer probes than screen pixels.
// The four refinements and final intersection interpolation preserve a sharp hit colour.
int steps=int(clamp(ceil(length(delta*screenLimit*vec2(textureSize(OpaqueDepth,0)))/128.0),8.0,32.0));
float previous=0.0,oldGap=-1e6;vec2 hit=vec2(0);float gap;
for(int i=1;i<=32;i++){
if(i>steps)break;
float f=float(i)/float(steps)*screenLimit;
float t=(f*distance/b.w)/mix(1.0/a.w,1.0/b.w,f);
vec2 uv;if(!sampleRay(origin,direction,t,uv,gap))return vec4(0);
if(gap>=0.0&&oldGap<0.0){
float lo=previous,hi=t,lowGap=oldGap,highGap=gap;
for(int j=0;j<4;j++){
float mid=(lo+hi)*.5;vec2 q;float delta;
if(!sampleRay(origin,direction,mid,q,delta))return vec4(0);
if(delta>=0.0){hi=mid;highGap=delta;}else{lo=mid;lowGap=delta;}
}
hi=mix(lo,hi,clamp(-lowGap/max(highGap-lowGap,1e-12),0.0,1.0));
if(!sampleRay(origin,direction,hi,hit,gap))return vec4(0);
// A bounded thickness rejects a silhouette jump into unrelated distant geometry.
float thickness=.15+.002*length(origin+direction*hi);
gap=length(origin+direction*hi)-length(reconstruct(hit,textureLod(OpaqueDepth,hit,0).r));
if(abs(gap)>thickness||hi<.08)return vec4(0);
float edge=min(min(hit.x,hit.y),min(1.0-hit.x,1.0-hit.y));
float confidence=smoothstep(0.0,.04,edge)*(1.0-smoothstep(distance*.85,distance,hi))
*(1.0-smoothstep(thickness*.65,thickness,abs(gap)))*smoothstep(.08,.2,hi);
return vec4(hit,hi,confidence);
}
previous=t;oldGap=gap;
}
return vec4(0);
}
void main(){
vec3 dx=dFdx(relativePosition),dy=dFdy(relativePosition);
vec2 mx=dFdx(maskUv),my=dFdy(maskUv),tx=dFdx(blockUv),ty=dFdy(blockUv);
float det=mx.x*my.y-mx.y*my.x;
float family=floor(maskUv.y*.5);
vec2 materialUv=vec2(maskUv.x,maskUv.y-family*2.0);
vec3 base=normalize(cross(dx,dy));if(dot(base,-relativePosition)<0.0)base=-base;
if(abs(det)<1e-14||length(relativePosition)>=ReflectionParams.w)discard;
// The final native depth includes entities rendered after the landscape capture.
float finalDepth=texelFetch(ReceiverDepth,ivec2(gl_FragCoord.xy),0).r;
vec2 screenUv=gl_FragCoord.xy/vec2(textureSize(ReceiverDepth,0));
if(finalDepth>0.0&&length(reconstruct(screenUv,finalDepth))+.005<length(relativePosition))discard;
float intensity=ReflectionParams.z;
if(intensity<=0.0)discard;
vec4 texel=textureGrad(BlockAtlas,blockUv,tx,ty);if(family<.5&&texel.a<.99)discard;
vec2 center=(floor(materialUv*1280.0)+.5)/1280.0;
vec3 u=(dx*my.y-dy*mx.y)/det,v=(dy*mx.x-dx*my.x)/det;
vec3 point=relativePosition+u*(center.x-materialUv.x)+v*(center.y-materialUv.y);
vec4 material=textureLod(MaterialAtlas,center,0);
vec2 slope=(material.rg*255.0-128.0)/127.0*intensity*(family>1.5?.24:2.0);
vec3 normal=normalize(base+normalize(u)*slope.x+normalize(v)*slope.y);
if(any(isnan(normal))||any(isinf(normal)))discard;
vec4 hit=trace(point,normal,base);if(hit.w<=0.0)discard;
vec3 reflected=textureLod(OpaqueColor,hit.xy,0).rgb;
if(material.b>.3){
vec2 stepUv=vec2(material.b*3.0)/vec2(textureSize(OpaqueColor,0));
float reference=length(reconstruct(hit.xy,textureLod(OpaqueDepth,hit.xy,0).r));
vec3 sum=vec3(0);float weight=0;
for(int i=0;i<4;i++){
vec2 uv=hit.xy+vec2(i%2==0?-1:1,i<2?-1:1)*stepUv;
float d=textureLod(OpaqueDepth,uv,0).r;
if(d>0.0&&abs(length(reconstruct(uv,d))-reference)<.25+reference*.002){sum+=textureLod(OpaqueColor,uv,0).rgb;weight+=1.0;}
}
if(weight>0.0)reflected=sum/weight;
}
float nv=clamp(dot(normal,normalize(-point)),0.0,1.0),grazing=1.0-nv,g2=grazing*grazing;
// Metal tint is sampled at the same texel centre, never at the individual screen fragment.
vec2 localDelta=center-materialUv;
vec2 centerBlock=blockUv+(tx*my.y-ty*mx.y)/det*localDelta.x+(ty*mx.x-tx*my.x)/det*localDelta.y;
vec3 albedo=textureLod(BlockAtlas,centerBlock,0).rgb*tint.rgb;
vec3 f0=mix(vec3(family>1.5?.02:.04),albedo*albedo,material.a);
vec3 fresnel=f0+(1.0-f0)*(g2*g2*grazing);
float fade=(1.0-smoothstep(ReflectionParams.w*.75,ReflectionParams.w,length(point)))*hit.w;
vec3 weight=clamp(fresnel*ReflectionParams.y*(1.0-material.b*material.b)*fade,0.0,.9);
vec3 scene=texelFetch(SceneColor,ivec2(gl_FragCoord.xy),0).rgb;
vec3 result=mix(scene*scene,reflected*reflected,weight);
if(any(isnan(result))||any(isinf(result)))discard;
fragColor=vec4(sqrt(clamp(result,0.0,1.0)),1);
}
@@ -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"]
}
@@ -184,6 +184,9 @@
"NaturalSnowEditMixin"
],
"client": [
"client.ReflectionSceneMixin",
"client.PbrAnimationClockMixin",
"client.PbrAnimationSpriteMixin",
"client.PackSelectionScreenMixin",
"client.FourneauFireMixin",
"client.BoatPassengerPickMixin",
@@ -278,7 +281,12 @@
"client.PixelShadowRendererMixin",
"client.PixelShadowLevelMixin",
"client.PixelShadowDirtyMixin",
"client.PreferredGraphicsApiMixin"
"client.PreferredGraphicsApiMixin",
"client.NaturalWaterRegionMixin",
"client.NaturalWaterFluidMixin",
"client.NaturalWaterFogMixin",
"client.NaturalWaterFogColorMixin",
"client.NaturalWaterSectionCopyAccess"
],
"injectors": {
"defaultRequire": 1
+1 -1
View File
@@ -1,6 +1,6 @@
name = "Sanctuary"
author = "KOKA99CAB"
version = "beta.142"
version = "beta.146"
pack-format = "packwiz:1.1.0"
[index]
+27
View File
@@ -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"
+7
View File
@@ -8,3 +8,10 @@ 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.
La livraison locale beta.145 ajoute le [PBR de l’eau et du verre ainsi que les
reflets pixélisés](../docs/pbr-ssr.md). SSR désactivé par défaut ; aucune
dépendance graphique externe.