Compare commits

...
Author SHA1 Message Date
koka 580b2414da Document beta.143 publication and Prism synchronization
Build Sanctuary / build (push) Waiting to run
2026-09-18 12:44:49 +02:00
koka 97ff2b54cb Slow seasonal snow pacing in beta.143
Build Sanctuary / build (push) Waiting to run
2026-09-18 12:41:27 +02:00
koka 59346d7f0a Document verified beta.142 publication and Prism synchronization
Build Sanctuary / build (push) Waiting to run
2026-09-18 05:08:09 +02:00
koka ea151dd5df Prefer native Vulkan and stabilize PBR lighting in beta.142
Build Sanctuary / build (push) Waiting to run
2026-09-18 05:05:49 +02:00
koka 7ce01c47f2 Document beta.141 publication and verified instance update
Build Sanctuary / build (push) Canceled after 0s
2026-09-18 04:13:00 +02:00
koka 93ed5574a4 Drive PBR normals with cached local light directions in beta.141
Build Sanctuary / build (push) Canceled after 0s
2026-09-18 04:11:03 +02:00
koka c9f5c56e68 Document verified beta.140 publication and Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-18 03:15:07 +02:00
koka 6e1b3b7108 Add colored dynamic lights and smooth hue-preserving fades for beta.140
Build Sanctuary / build (push) Canceled after 0s
2026-09-18 03:13:43 +02:00
39 changed files with 1287 additions and 182 deletions
+43 -6
View File
@@ -20,12 +20,14 @@ 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.139** ajoute les **lumières colorées** optionnelles : les vrais
blocs lumineux teintent les surfaces voisines, même quand la source est hors
champ. Intensité indépendante, réglée à 35 % ; obstacles natifs et cache local.
Le [ticket](docs/colored-lights-beta139.md) décrit les limites et vérifications.
Release publiée et même instance Prism synchronisée ; sauvegardes et réglages conservés.
La [vérification Vulkan antérieure](docs/shader-vulkan-beta138.md) reste disponible.
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 linstance 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à
latelier, les œufs et la neige saisonnière dans linstance **Sanctuary Beta**.
Les [synchronisations antérieures](docs/git-sync-beta092.md) restent documentées.
@@ -36,6 +38,41 @@ 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,
calculs de reflets bornés et intensité initiale 80 %. Choix personnels conservés.
[Contrat et vérifications](docs/pbr-refresh-beta142.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.142/Sanctuary-beta.142.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.142/Sanctuary-Test-beta.142.mrpack).
## beta.141 — Direction locale et normales PBR
Relief et reflets orientés par les lumières locales. Cache compact, confiance
réduite pour les sources opposées, objets portés et fondus. Activer PBR et
Lumières colorées ; portée PBR 32 blocs.
[Contrat et vérifications](docs/directional-lights-beta141.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.141/Sanctuary-beta.141.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.141/Sanctuary-Test-beta.141.mrpack).
## beta.140 — Lumières portées et fondus
Mains et tête, distances 8/16/32/64, fondus, cuivre vert, redstone rouge et
option pour la chaleur des torches. Lumières colorées activées à 30 % par défaut.
[Contrat et vérifications](docs/colored-dynamic-beta140.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.140/Sanctuary-beta.140.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.140/Sanctuary-Test-beta.140.mrpack).
## beta.139 — Lumières colorées
Teintes chaudes, sources des âmes bleues, redstone rouge, froglights et matériaux lumineux.
+112
View File
@@ -0,0 +1,112 @@
# LIGHT-140 — Lumières colorées portées et distance
Socle beta.139 `c05c338`, branche `codex/colored-dynamic-beta140`.
Minecraft 26.3 / Java 25. Livraison beta.140.
## Contrat
Relier les couleurs aux vraies sources de lumière dynamique : mains, tête,
cosmétiques, objets au sol et feu, selon le système existant. Renforcer la
teinte et activer les lumières colorées par défaut à 30 %. Ajouter une distance
d'affichage de 8, 16, 32 ou 64 blocs (16 par défaut), indépendante de la portée
native de chaque source. Augmenter la distance coûte davantage en mémoire et
calcul ; aucun chargement forcé de chunk. Les choix sauvegardés sont conservés.
Sources portées actualisées avec le système dynamique, sans reconstruire tout
le champ statique à chaque mouvement. Aucun changement de monde ou de règle
serveur. Vérifications natives OpenGL et Vulkan, puis construction et archives.
## Couleurs et fondus
Torches ordinaires : température native par défaut ; option « Torches chaudes »
pour retrouver l'orangé. Glowstone jaune, lave jaune-rouge, redstone rouge saturé,
cuivre vert (posé, en main ou sur la tête). Le cuivre est reconnu avant les
familles génériques de torches et lanternes. Les palettes explicites restent
indépendantes des packs ; le repli des autres matériaux suit leurs textures.
Le rendu s'atténue sur le dernier quart de la distance choisie. Le champ précédent
reste disponible pendant un fondu de 300 ms vers le nouveau champ ; l'activation
et les sources dynamiques ont aussi un fondu. Le changement de distance est
interpolé. Les sources mobiles utilisent la même sélection bornée et les mêmes
intensités que la lumière dynamique existante, avec interpolation visuelle des
positions et couleurs. Elles héritent de sa propagation radiale, y compris de
ses limites d'occultation ; les blocs posés conservent leur propagation avec murs.
Le volume varie réellement selon la distance (56, 72, 104 ou 168 blocs de côté,
avec marge de propagation). Les grandes distances augmentent la mémoire et le
délai de reconstruction ; la construction reste répartie entre les images.
Deux champs coexistent brièvement pour le fondu. Seuls les chunks déjà chargés
sont lus. La portée lumineuse native des torches n'est pas allongée.
## Frontières de saturation
Le stockage beta.139 tronquait les canaux faibles séparément en fin de portée :
un bleu pouvait perdre son rouge avant son vert, puis devenir plus saturé sur
une frontière visible. Le stockage logarithmique conserve maintenant une marge
sous le niveau lumineux natif zéro. Le shader applique la coupure à l'énergie
globale en conservant les rapports RGB. La réponse devient linéaire près du noir,
sans amplification par racine quatrième des très faibles contributions.
La transition des champs interpole le résultat colorimétrique rendu, pas une
énergie ensuite amplifiée. Les sources mobiles ont une atténuation de fondu
compensée pour la même réponse. Le test contrôle les rapports rouge/bleu et
vert/bleu d'une torche des âmes de 1 à 9 blocs, jusqu'au bord de sa portée.
## Vérifications natives
Minecraft 26.3 / Java 25 sur Apple M1, mondes de test neufs de graine 122 :
- OpenGL 4.1 Metal : **2 min 40 s**, `build/colored140-opengl.log`.
- Vulkan / MoltenVK 1.4.2, profondeur 01 et transparence améliorée :
**2 min 26 s**, `build/colored140-vulkan.log`.
Les deux exécutions passent `COLORED139_PASS` et `COLORED140_PASS` :
propagation avec murs, sources hors champ, lampes allumées/éteintes, retrait,
rechargement des ressources, cache, déplacement/retour de caméra, OFF/zéro,
préférences et traductions FR/EN. Les nouveaux contrôles couvrent la teinte
bleue jusqu'à neuf blocs, la torche native inchangée, l'option chaude, le cuivre
vert et le rouge saturé, les deux mains et le cosmétique de tête, la position
en troisième personne, les quatre distances et un fondu sans dépassement.
Dans la même scène contrôlée, la somme d'écarts OFF/torche chaude à 30 % vaut
3 926 093 / 3 926 092 sur OpenGL/Vulkan, contre 2 025 577 en beta.139 à 35 %.
Cette mesure vérifie l'accentuation demandée, pas la performance. Le test fige
uniquement le scintillement de la lightmap native ; cette sonde et les tests
client ne sont pas distribués. Les captures finales sont conservées sous
`build/colored140-opengl-screenshots/` et `build/colored140-vulkan-screenshots/`.
Comparatif autonome : `build/Shader-beta.140-comparaison.html`.
Pas de validation des pilotes Windows de l'utilisateur revendiquée. Les
limites de beta.139 pour le rendu sous l'eau/lave et les panoramas restent
ouvertes. Les sources dynamiques gardent l'occultation du système existant.
Aucun monde personnel ouvert ni modifié.
## Construction et archives
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussit en **2 min 22 s**, 126 tâches. Le GameTest serveur dédié reste exclu
conformément au refus EULA antérieur ; les tests clients ci-dessus sont exécutés
séparément. Log : `build/shader140-build.log`.
La comparaison avec beta.139 trouve **18 entrées de production modifiées**,
limitées aux lumières dynamiques/colorées, options et aides FR/EN. Les sources
des JAR correspondent aux fichiers du dépôt. Les archives normale/Test contiennent
le même JAR Sanctuary ; la version, les dépendances exactes, les textures de
gemmes et de la clé de Steve, l'absence de classes de test et le template complet
sont vérifiés. Reçu : `build/shader140-artifact.json`.
SHA-256 du JAR : `853284c1a61c7fce12edd2f7763717321eef56977290eb0e7c5db5e30bff442f`.
- Sanctuary-beta.140.mrpack : `1b366746e4d6bebfbe0115c849524bdeb81c14eee89a2510f9dbe18808974d6a`.
- Sanctuary-Test-beta.140.mrpack : `50c3cdf759117bb5286d41b4535fde15d9d84deaac833b7f8f61c4a0d29b72e7`.
## Livraison
[Release beta.140](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.140)
publiée sur le commit source `6e1b3b71088437fe57cf2b9b979b6d62711628d3` ; canal stable
`2c360a0759d4a164f82c89ac2bef04ff69d50875` vérifié après publication.
Deux synchronisations isolées puis deux dans la même instance Sanctuary Beta
réussissent. Les **923 fichiers personnels** suivis gardent leurs hashes.
Sauvegarde préalable : `sanctuary-backups/before-beta.140/`. Aucun monde personnel
ouvert. Reçus : `build/shader140-isolated.json` et `build/shader140-prism.json`.
Archives normale/Test/template et comparaison copiées dans `sanctuary-beta/build/`.
+127
View File
@@ -0,0 +1,127 @@
# LIGHT-141 — Direction locale pour les normales PBR
Socle beta.140 `c9f5c56`, branche `codex/directional-lights-beta141`.
Minecraft 26.3 / Java 25. Livraison beta.141.
## Contrat
Estimer une direction d'arrivée depuis les six voisins du champ lumineux,
avec intensité et confiance. Conserver cette estimation dans une texture
compacte reconstruite avec le champ, uniquement quand le PBR est actif.
Les sources portées utilisent leurs positions déjà disponibles. Brancher ces
directions sur les normales et reflets PBR existants, sans nouveau bouton.
Les sources opposées doivent réduire la directionnalité, sans bascule brutale.
Les champs précédent/courant gardent leur fondu et les obstacles restent pris
en compte pour les blocs posés. Aucun changement de lumière de gameplay,
de sauvegarde ou de génération ; aucune promesse de lancer de rayons exact.
## Programme de vérification
Contrôles natifs OpenGL/Vulkan : direction, sources opposées, murs, rendu de
relief en intérieur, déplacement des sources, transitions et PBR OFF.
Régression des lumières beta.139/140 puis check/build et assemblage. GameTest
serveur dédié exclu selon le refus EULA antérieur. Aucun monde personnel ouvert.
## Réalisation
Le champ RGB conserve une texture directionnelle RGBA8 supplémentaire :
les trois composantes codent la direction pondérée, l'alpha sa réponse lumineuse.
Les voisins plus lumineux et accessibles contribuent à l'estimation ; les
arrivées opposées s'annulent dans le vecteur, sans supprimer l'éclairage natif.
Le shader interpole les moments pondérés avant normalisation, y compris entre
les champs précédent et courant. La confiance réduit le relief quand aucune
direction ne domine.
Cette texture est préparée dans le budget de reconstruction existant, avec
un maximum de six voisins par cellule éclairée et une table d'énergie réutilisée.
Aucune recherche de sources ni rayons par pixel pour les blocs posés. La
lecture GPU interpole huit cellules par champ ; les deux champs sont utilisés
pendant le fondu. Les sources mobiles réutilisent les positions, intensités et
transitions déjà collectées, dans la limite existante de 128 sources.
Une texture directionnelle occupe environ 1,5 Mo à 16 blocs de distance et
19 Mo à 64 blocs, par copie CPU/GPU ; deux champs coexistent lors des transitions.
Le champ directionnel n'est construit que si PBR est actif avec une intensité
non nulle. Sa désactivation libère les directions après la transition.
Le coût est borné par ces réglages, sans garantie de FPS.
La passe PBR applique la différence entre normale de base et normale perturbée,
puis un reflet GGX local. Elle garde sa portée de 32 blocs et les réglages PBR
existants ; Lumières colorées doit être actif. Le soleil et la lune continuent
à fonctionner lorsque les lumières colorées sont coupées. Les aides FR/EN
expliquent cette dépendance.
C'est une direction dominante approximative : deux lumières colorées opposées
ne produisent pas deux reflets indépendants. Les sources mobiles gardent la
propagation radiale et les limites d'occultation existantes. Les normales PBR
concernent les surfaces de blocs prises en charge, pas tous les modèles de mobs.
Les exclusions existantes des panoramas et de l'immersion restent inchangées.
## Premier essai ciblé
L'essai natif OpenGL ciblé réussit en **1 min 12 s** : direction gauche/droite,
annulation des sources opposées avec énergie conservée, occultation par un mur,
relief sur la pierre en intérieur et avec une torche en main, stabilité au repos,
libération des directions avec PBR OFF et maintien du chemin solaire avec
Lumières colorées OFF. La somme d'écarts PBR OFF/ON sur la pierre vaut 878 114
au réglage PBR 100 % ; c'est une comparaison de pixels, pas une mesure de FPS.
Log : `build/directional141-focused.log`.
Les essais de préparation ont révélé un conflit de nom de matrice GLSL, corrigé,
puis des erreurs de fixture (angle 180° normalisé par Minecraft, suppression
d'entités alors que la sélection était vide, commande d'heure déjà à minuit),
corrigées sans changer le rendu.
## Validation complète OpenGL
La chaîne complète réussit en **3 min 1 s** sur OpenGL / Apple M1 :
`COLORED139_PASS`, `COLORED140_PASS` et `DIRECTIONAL141_PASS`.
Elle réunit les contrôles de couleurs, objets portés, caméra, occultation,
rechargement des ressources, préférences, distances/fondus et directions PBR.
Le test ciblé est reproductible avec `-PsanctuaryDirectional141Only=true` ;
la chaîne complète utilise `-PsanctuaryDirectional141ClientTests=true` avec
les deux suites de lumières existantes.
Log : `build/directional141-opengl.log`. Captures natives conservées dans
`build/directional141-opengl-screenshots/`. Comparaison autonome sans retraitement :
`build/Shader-beta.141-comparaison.html`.
## Validation complète Vulkan
La même chaîne réussit en **2 min 56 s** sur Vulkan / Apple M1, MoltenVK 1.4.2,
profondeur 01 et transparence améliorée. Les trois marqueurs de réussite sont
présents dans `build/directional141-vulkan.log`. L'écart PBR local OFF/ON vaut
878 107, contre 878 114 sous OpenGL dans cette scène. Les captures natives sont
conservées dans `build/directional141-vulkan-screenshots/`.
Les deux moteurs vérifient la compilation, les chemins actifs et désactivés,
le cache, les murs, les sources opposées et portées, ainsi que les régressions
beta.139/140. Pas de validation des pilotes Windows de l'utilisateur revendiquée.
## Construction et archives
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussit en **2 min 35 s**, 126 tâches. Le GameTest serveur dédié reste exclu
selon le refus EULA antérieur. Les suites clientes ci-dessus ont été exécutées
séparément. Log : `build/shader141-build.log`.
Comparaison à beta.140 : **8 entrées de production modifiées**, limitées au
champ lumineux, à sa préparation avant le PBR, au shader PBR et aux aides FR/EN.
Les archives normale/Test contiennent le même JAR Sanctuary, sans classes
de test. Version, dépendances exactes, sources des JAR, textures de gemmes et
de la clé de Steve, et template complet sont vérifiés.
Reçu : `build/shader141-artifact.json`.
SHA-256 du JAR : `660f29c3fa77fee4de6a8db5673c61619985804494f70e961998da36f396d0f1`.
- Sanctuary-beta.141.mrpack : `08df23dac9e98fbfa2935f747345c38244bddc3a3d9371c6416f251e85a7eb19`.
- Sanctuary-Test-beta.141.mrpack : `a300ef5bfff7b6990c9f5c2760e9c3a23d7a27aeb10b8f04d29db9a7409fa0e1`.
## Livraison
[Release beta.141](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.141)
publiée sur le commit source `93ed5574a4f77bd3a083b254498f4994efe31179` ; canal stable
`7e6c84400f80db24bf5204cd21d628194ae9f0f7` vérifié après publication.
Deux synchronisations isolées puis deux dans la même instance Sanctuary Beta
réussissent. Les **923 fichiers personnels** suivis gardent leurs hashes.
Sauvegarde préalable : `sanctuary-backups/before-beta.141/`. Aucun monde personnel
ouvert. Reçus : `build/shader141-isolated.json` et `build/shader141-prism.json`.
Archives normale/Test/template et comparaison copiées dans `sanctuary-beta/build/`.
+49
View File
@@ -14,6 +14,55 @@ 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.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
80 %, calculs de reflets bornés. [Contrat et vérifications](pbr-refresh-beta142.md).
[Release beta.142](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.142)
publiée ; canal `d661260e2bbabb0326c86d6cc543daf986979596`. 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.142/`. Archives vérifiées dans `sanctuary-beta/build/`.
Un choix OpenGL explicite reste conservé : sélectionner Vulkan dans les réglages
vidéo et redémarrer pour changer ce choix. La capture Windows de bruit en
mouvement reste à reconfirmer sur son pilote ; les contrôles OpenGL/Vulkan Mac
et la correction du domaine numérique du reflet sont documentés dans le ticket.
## beta.141 — Direction des lumières pour les normales PBR
Directions locales mises en cache, sources portées, confiance réduite lorsque
les sources s'opposent et fondus. Activer PBR et Lumières colorées ; portée PBR
de 32 blocs. [Contrat et vérifications](directional-lights-beta141.md).
[Release beta.141](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.141)
publiée ; canal `7e6c84400f80db24bf5204cd21d628194ae9f0f7`. 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.141/`. Archives vérifiées dans `sanctuary-beta/build/`.
## beta.140 — Lumières portées et fondus
Couleurs des objets en main et sur la tête, distances 8/16/32/64 et fondus
préservant la teinte. Torches natives par défaut, option chaude, cuivre vert
et redstone rouge. [Contrat et vérifications](colored-dynamic-beta140.md).
[Release beta.140](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.140)
publiée ; canal `2c360a0759d4a164f82c89ac2bef04ff69d50875`. 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.140/`. Archives vérifiées dans `sanctuary-beta/build/`.
## beta.138 — Densité des rayons selon la météo
Brouillard plus dense et diffus, précipitations et atténuation de l'orage.
+173
View File
@@ -0,0 +1,173 @@
# PBR-142 — Stabilité du relief et Vulkan par défaut
Socle beta.141 `7ce01c4`, branche `codex/pbr-refresh-beta142`.
Minecraft 26.3 / Java 25. Livraison beta.142.
## Contrat
Corriger le clignotement lors de la reconstruction des sections PBR : conserver
le rendu précédent jusqu'à ce que le remplaçant soit disponible et éviter les
reconstructions dues seulement aux lumières dynamiques. Une invalidation pendant
une reconstruction doit être conservée, sans annuler indéfiniment le travail.
Le PBR passe à 80 % par défaut, valeur confirmée par l'utilisateur ; les choix
explicitement enregistrés restent conservés.
La capture `2026-09-18_04.19.34.png` montre des points très clairs, plus fins
que les pixels des textures, sur des parois et un plafond de grotte. L'utilisateur
confirme OpenGL et leur disparition quand seul le PBR est coupé. Examiner
les lectures de textures et les calculs aux angles rasants séparément du cache.
Tests natifs prévus : recalcul de section sous observation, invalidations
répétées, remplacement atomique, suppression réelle de blocs, lumière portée,
cache et préférences ; OpenGL et Vulkan. Pas de monde personnel ouvert.
## Précision du signalement et implémentation
L'utilisateur précise ensuite que le bruit apparaît **en mouvement**, et demande
le passage à Vulkan. Il ne faut donc pas attribuer les points de sa capture au
seul clignotement de reconstruction : ce sont deux vérifications distinctes.
- Les sections PBR publiées restent affichées jusqu'au remplacement de leur
maillage. Une nouvelle invalidation durant la construction est mémorisée ;
elle n'annule plus continuellement le travail. Le budget permanent reste
24 Mio, avec temporairement l'ancien et le nouveau maillage d'une section.
- Les invalidations de lumière dynamique ne régénèrent plus la géométrie PBR.
Les changements de blocs et de lumière du ciel restent pris en compte.
- Lectures explicites des texels de profondeur/scène et du niveau de matériau,
gradients de texture calculés avant les rejets de fragments. Les produits
scalaires des reflets sont bornés, la puissance entière de Fresnel est
explicite, les résultats non finis ne remplacent pas la scène native.
Aucun flou ajouté aux textures.
- Le choix graphique natif « Par défaut » devient « Vulkan (par défaut) »
(FR/EN) et essaie Vulkan en premier. Minecraft conserve son repli OpenGL si
Vulkan ne peut pas être initialisé. Un choix **OpenGL explicite** reste
disponible ; pour une installation ainsi réglée, sélectionner Vulkan dans
les réglages vidéo puis redémarrer. Aucun moteur externe ni VulkanMod ajouté.
- 80 % est la nouvelle intensité PBR initiale. Les valeurs déjà enregistrées
explicitement restent conservées.
Le code Minecraft 26.3 a été inspecté : `PreferredGraphicsApi.VULKAN` essaie
Vulkan puis OpenGL ; le choix natif `DEFAULT` essayait auparavant OpenGL d'abord.
Tous les effets Sanctuary utilisent déjà l'API graphique native. Le seul hook
OpenGL direct est le correctif spécifique de lecture des panoramas dans
`GlCommandEncoder` ; il ne remplace pas la voie Vulkan native.
Référence pour les lectures après contrôle divergent :
[documentation Khronos des samplers](https://wikis.khronos.org/opengl/Sampler_%28GLSL%29).
La correction rend ces lectures explicites ; elle ne constitue pas à elle seule
une reproduction du défaut sur le pilote Windows de l'utilisateur.
## Observations de diagnostic
Le détecteur de points blancs isolés (minimum RGB 170, au moins cinq voisins
sur huit sous 120) compte 669 points sur l'image utilisateur et zéro sur la
première grotte de contrôle. Il s'agit de scènes, résolutions et pilotes différents,
pas d'un avant/après reproduisant exactement le bug utilisateur. Les rendus
natifs OpenGL de contrôle étaient déjà exempts de ce grain avant les nouvelles
protections du shader ; le signalement Windows reste donc à reconfirmer là-bas.
Le test de rafraîchissement conserve les mêmes maillages publiés durant douze
invalidations consécutives, compare l'image et vérifie que des remplacements
aboutissent. Le test de torche alterne quatre fois les torches normales/des âmes
sans reconstruction des normales. Le contrôle de mouvement presse réellement
Avancer, maintient la torche et le PBR actifs et vérifie un déplacement d'au moins
un bloc avant de vérifier les 24 captures. Les angles -30°, 0° et 35° sont aussi
contrôlés, ainsi que les effets combinés et les préférences.
### Domaine invalide du reflet de la torche en main
Pour la lumière du joueur en vue subjective, la source est la caméra : le
vecteur lumière et le vecteur de vue sont parallèles. L'ancien
`pow(1 - max(dot(v,h),0),5)` n'avait aucune borne supérieure pour ce produit
scalaire. Un calcul reproductible Float32 (100 000 positions, graine 142)
donne 19 991 bases légèrement négatives, jusqu'à -2,3841858e-7, contre zéro
après clamp. C'est une reproduction numérique d'un **domaine de calcul invalide**,
pas une reproduction des pixels produits par le pilote utilisateur.
GLSL 3.30 ne définit pas `pow` pour une base négative, même avec un exposant
entier : [spécification Khronos, fonctions exponentielles](https://registry.khronos.org/OpenGL/specs/gl/GLSLangSpec.3.30.pdf).
Le correctif borne les cosinus à [0,1] et remplace cette puissance par des
multiplications. La torche alignée sur la caméra ne dépend donc plus du traitement
de cette entrée invalide par le pilote. Reçu numérique ignoré :
`build/pbr142-fresnel-domain.json`.
### Mise à jour des fixtures antérieures
La suite Vulkan complète s'est arrêtée au contrôle SSGI de coin : l'oracle
ne trouvait plus de pierre neutre (`rows=0`), car les lumières colorées ajoutées
depuis beta.139/140 teintaient la pierre avec la lave. Le test de proximité SSGI
et le test PBR solaire isolent désormais leur effet en coupant cette autre passe.
Aucun seuil n'a été diminué et aucun changement de rendu SSGI n'est livré.
Le sous-ensemble en échec est relancé séparément ; les étapes précédentes
(ombres, CTM, bloom, lens flare, déplacement SSGI) avaient réussi.
## Vérifications natives
Sur Mac Apple M1, Java 25 et Minecraft 26.3 :
- OpenGL : suite couleurs/directions/rafraîchissement en 3 min 36 s, puis
déplacement continu avec torche active en 1 min 36 s. Zéro point blanc isolé
sur les 24 images de mouvement et les trois angles de grotte.
- Vulkan/MoltenVK 1.4.2 : sélection automatique sans argument de moteur,
couleurs/directions/rafraîchissement en 3 min 47 s. Dernier test continu avec
torche active en 1 min 37 s, zéro point blanc isolé sur les 24 images.
- Ombrages lune/midi/feuilles, distances et précisions, stabilité temps réel,
CTM, bloom (minerais, torche, lampe, lave, soleil), émissifs indépendants et
lens flare validés sous Vulkan dans la suite d'effets. Le déplacement SSGI
sur douze blocs passe également. La suite s'arrête ensuite sur l'oracle
ancien de couleur de pierre décrit ci-dessus : ne pas présenter ce lancement
de 7 min 15 s comme une réussite complète.
- Reprise ciblée après isolation de cet oracle : SSGI près des surfaces puis
PBR extérieur réussis sous Vulkan en 2 min 55 s. Proximité 35/100, haute
résolution, léger mouvement, toit ajouté/retiré, reload, resize, zéro/OFF et
effets combinés. Gain SSGI au coin test : 6,01 sur 99 lignes à 35 %.
- Les nouvelles valeurs initiales et les choix enregistrés sont testés, dont
une intensité PBR explicitement enregistrée à 50 qui reste à 50.
Les premiers essais du nouveau contrôle de mouvement ont permis de corriger
son entrée clavier : `pressKey` ne maintient pas une touche enfoncée. Le test
final utilise `holdKey` puis `releaseKey`, vérifie le déplacement réel et refuse
la disparition de la lumière portée ou du PBR pendant les captures.
Les jeux de test utilisent uniquement des mondes neufs et ignorés. La capture
et le pilote Windows utilisateur n'ont pas été reproduits à l'identique ; leur
retest reste ouvert malgré les corrections numériques et les contrôles natifs.
## Construction et archives
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussit en **2 min 38 s**, 126 tâches. Le GameTest serveur dédié reste exclu
selon le refus EULA antérieur ; les suites clientes ci-dessus ont réellement
utilisé les deux moteurs natifs. `git diff --check` passe.
Les archives normale/Test embarquent le même JAR, leurs versions/index/hashes
sont vérifiés, leurs sources correspondent aux fichiers courants. Le template
contient exactement les ressources attendues. Les onze entrées de production
modifiées par rapport à beta.141 sont limitées au PBR, au choix graphique et
aux traductions ; les ressources de jeu antérieures sont conservées.
SHA-256 du JAR : `0bc2da9df2ef004e6dc78932cfde7b31b7717c63c83ceed789d151177c41bc1a`.
- `Sanctuary-beta.142.mrpack` : 10464746 octets ; SHA-256 `dba4fd8e1e3b20136faa1c1de3494df3cb250c87de199c04e07883c786ca48ee`.
- `Sanctuary-Test-beta.142.mrpack` : 10483672 octets ; SHA-256 `d56b81a4aea6649a40a3c49dedee904989943a518ad0ae7c520b808dd0f9b9a7`.
Reçu détaillé ignoré : `build/shader142-artifact.json`.
## Livraison réalisée
[Release beta.142](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.142)
publiée depuis `ea151dd5df8c7c5abd0107656f40c723efbf2ebe`. Canal stable :
`d661260e2bbabb0326c86d6cc543daf986979596`. Archives normale/Test, ZIP d'amorçage Prism et template
publiés avec vérification de leurs téléchargements ; tag et artefacts immuables.
Deux synchronisations isolées puis deux synchronisations de l'instance
**Sanctuary Beta** réussies. Les 923 fichiers personnels suivis ont conservé
leurs hashes, notamment mondes, réglages et packs personnels. Aucun monde
personnel ouvert. Sauvegarde préalable : `sanctuary-backups/before-beta.142/`.
L'instance locale conserve son réglage graphique natif « default », qui démarre
désormais avec Vulkan prioritaire. Un Windows explicitement réglé sur OpenGL
doit sélectionner Vulkan dans les réglages vidéo et redémarrer.
Archives locales également dans `sanctuary-beta/build/`. Le checkout de travail
historique modifié `sanctuary-beta` n'a pas été réinitialisé ; seules ces archives
ignorées y ont été déposées. Sources sur `codex/pbr-refresh-beta142`.
+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/`.
+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.139
pack_version=beta.139
mod_version=beta.143
pack_version=beta.143
resource_pack_version=beta.121
maven_group=fr.koka.sanctuary
jei_version=30.32.0-sanctuary.3
+4
View File
@@ -34,6 +34,10 @@ if (clientRenderTests) {
if (improvedTransparency.isPresent()) {
systemProperty('sanctuary.test.improvedTransparency', improvedTransparency.get())
}
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'))
systemProperty('sanctuary.test.colored140', providers.gradleProperty('sanctuaryColored140ClientTests').getOrElse('false'))
systemProperty('sanctuary.test.noVsync', providers.gradleProperty('sanctuaryClientNoVsync').getOrElse('false'))
systemProperty('sanctuary.test.quick', providers.gradleProperty('sanctuaryQuickTests').getOrElse('false'))
systemProperty('sanctuary.test.introVisualOnly', providers.gradleProperty('sanctuaryIntroVisualOnly').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);}
@@ -20,7 +20,7 @@ public final class ColoredLights139ClientChecks implements FabricClientGameTest
finally{System.clearProperty("sanctuary.test.freezeBlockFlicker");}
}
static void run(ClientGameTestContext c,TestServerContext server){
c.runOnClient(m->{preferences();m.options.particles().set(net.minecraft.server.level.ParticleStatus.MINIMAL);VanillaLight.setEnabled(true);VanillaLight.setSaturation(100);VanillaLight.setPbr(false);VanillaLight.setSsgi(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);VanillaLight.setLensFlare(false);VanillaLight.setEdgeHighlights(false);VanillaLight.setPixelShadows(false);VanillaLight.setSunshafts(false);VanillaLight.setColoredLights(false);VanillaLight.setColoredLightIntensity(35);});
c.runOnClient(m->{preferences();VanillaLight.setWarmTorches(true);m.options.particles().set(net.minecraft.server.level.ParticleStatus.MINIMAL);VanillaLight.setEnabled(true);VanillaLight.setSaturation(100);VanillaLight.setPbr(false);VanillaLight.setSsgi(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);VanillaLight.setLensFlare(false);VanillaLight.setEdgeHighlights(false);VanillaLight.setPixelShadows(false);VanillaLight.setSunshafts(false);VanillaLight.setColoredLights(false);VanillaLight.setColoredLightIntensity(35);});
camera(c,server,100.5,101,104.5,70);c.waitTicks(30);
server.runOnServer(s->{command(s,"time set 18000");
for(int x=88;x<=112;x++)for(int z=92;z<=116;z++)for(int y=100;y<=109;y++)
@@ -36,6 +36,12 @@ public final class ColoredLights139ClientChecks implements FabricClientGameTest
int r=color>>16&255,g=color>>8&255,b=color&255;
if(block==Blocks.TORCH){check(r>g&&g>b,"Torch is warm");warm=delta;}
if(block==Blocks.SOUL_TORCH)check(b>g&&g>r,"Soul source is blue");
if(block==Blocks.SOUL_TORCH)for(int step=1;step<=9;step++){
final int distance=step;int sample=c.computeOnClient(m->ColoredLights.sample(SOURCE.offset(distance,0,0)));
int sr=sample>>16&255,sg=sample>>8&255,sb=sample&255;
double rr=sr==0?0:Math.pow(.8,(sb-sr)/8.0),gg=sg==0?0:Math.pow(.8,(sb-sg)/8.0);
check(Math.abs(rr-85/255.)<.02&&Math.abs(gg-189/255.)<.02,"Blue hue remains consistent at native distance "+step+" "+rr+" / "+gg);
}
if(block==Blocks.VERDANT_FROGLIGHT)check(g>r&&g>b,"Verdant froglight is green");
if(block==Blocks.PEARLESCENT_FROGLIGHT)check(r>g&&b>g,"Pearlescent froglight is pink");
if(block==Blocks.OCHRE_FROGLIGHT)check(r>g&&g>b,"Ochre froglight is warm");
@@ -73,8 +79,8 @@ public final class ColoredLights139ClientChecks implements FabricClientGameTest
private static void settle(ClientGameTestContext c){int before=c.computeOnClient(m->ColoredLights.renderedFrames());c.waitFor(m->ColoredLights.applied()&&ColoredLights.settled()&&ColoredLights.renderedFrames()>before+8,1600);c.waitTicks(8);}
private static long difference(Frame a,Frame b){long sum=0;for(int i=0;i<a.pixels().length;i++)sum+=channelDifference(a.pixels()[i],b.pixels()[i]);return sum;}
private static void preferences(){try{
var path=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");Files.deleteIfExists(path);forgetPreferences();check(!VanillaLight.coloredLights()&&VanillaLight.coloredLightIntensity()==35,"New option is opt-in, subtle at 35 percent");
Files.writeString(path,"{\"bloomIntensity\":20,\"ssgi\":false}");forgetPreferences();check(!VanillaLight.coloredLights()&&VanillaLight.coloredLightIntensity()==35&&!VanillaLight.ssgi()&&VanillaLight.bloomIntensity()==20,"Old preferences keep their choices");
var path=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");Files.deleteIfExists(path);forgetPreferences();check(VanillaLight.coloredLights()&&VanillaLight.coloredLightIntensity()==30,"New defaults are enabled at 30 percent");
Files.writeString(path,"{\"bloomIntensity\":20,\"ssgi\":false}");forgetPreferences();check(VanillaLight.coloredLights()&&VanillaLight.coloredLightIntensity()==30&&!VanillaLight.ssgi()&&VanillaLight.bloomIntensity()==20,"Old preferences keep their choices");
for(int n:new int[]{-1,0,35,100,400}){VanillaLight.setColoredLights(true);VanillaLight.setColoredLightIntensity(n);forgetPreferences();check(VanillaLight.coloredLights()&&VanillaLight.coloredLightIntensity()==Math.clamp(n,0,100),"Bounded persistent controls");}
}catch(java.io.IOException e){throw new AssertionError(e);}}
}
@@ -0,0 +1,59 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import fr.koka.sanctuary.client.DynamicLightClient;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.cosmetics.AccessoryService;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.EquipmentSlot;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.*;
import net.minecraft.network.chat.Component;
final class ColoredLights140ClientChecks {
private static final BlockPos SOURCE=new BlockPos(98,101,102),PROBE=new BlockPos(99,101,106);
static void run(ClientGameTestContext c,TestServerContext server){
c.runOnClient(m->{VanillaLight.setPbr(false);VanillaLight.setSsgi(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);VanillaLight.setLensFlare(false);VanillaLight.setEdgeHighlights(false);VanillaLight.setPixelShadows(false);VanillaLight.setSunshafts(false);VanillaLight.setColoredLightIntensity(30);VanillaLight.setWarmTorches(false);VanillaLight.setColoredLightDistance(16);});
source(c,server,Blocks.TORCH);enable(c,false);var nativeTorch=capture(c,"torch-native-off");enable(c,true);unchanged(nativeTorch,capture(c,"torch-native-on"),"Normal torch preserves vanilla colour");
c.runOnClient(m->VanillaLight.setWarmTorches(true));settle(c);var warm=capture(c,"torch-warm-30");long accent=difference(nativeTorch,warm);check(accent>2_025_577,"30 percent exceeds beta139 warm tint at 35 percent: "+accent);
c.runOnClient(m->VanillaLight.setWarmTorches(false));settle(c);unchanged(nativeTorch,capture(c,null),"Warm torch option can restore native tint");
for(String name:new String[]{"copper_torch","copper_lantern"}){
var copper=BuiltInRegistries.BLOCK.getValue(Identifier.parse("minecraft:"+name));check(copper!=Blocks.AIR&&copper.defaultBlockState().getLightEmission()>0,"Native copper source exists "+name);
source(c,server,copper);int value=c.computeOnClient(m->ColoredLights.sample(PROBE));check((value>>8&255)>(value>>16&255)&&(value>>8&255)>(value&255),"Copper emits green");capture(c,name+"-green");
}
server.runOnServer(s->{s.overworld().setBlockAndUpdate(SOURCE,Blocks.REDSTONE_LAMP.defaultBlockState());s.overworld().setBlockAndUpdate(SOURCE.below(),Blocks.REDSTONE_BLOCK.defaultBlockState());});c.waitTicks(40);settle(c);
int red=c.computeOnClient(m->ColoredLights.sample(PROBE));check(Math.pow(.8,((red>>16&255)-(red>>8&255))/8.)<.04,"Redstone uses saturated red");capture(c,"redstone-red-30");
source(c,server,Blocks.AIR);server.runOnServer(s->s.overworld().setBlockAndUpdate(SOURCE.below(),Blocks.CONCRETE.white().defaultBlockState()));
// Actual carried sources, synchronized through native equipment and accessory packets.
server.runOnServer(s->{command(s,"gamemode creative @a");var p=s.getPlayerList().getPlayers().getFirst();p.setItemSlot(EquipmentSlot.MAINHAND,new ItemStack(Items.SOUL_TORCH));});
c.waitFor(m->DynamicLightClient.snapshot(m.level).size()==1,200);c.waitTicks(45);enable(c,false);var carriedOff=capture(c,"held-soul-off");enable(c,true);c.waitTicks(25);var carriedOn=capture(c,"held-soul-blue");check(difference(carriedOff,carriedOn)>1000&&c.computeOnClient(m->ColoredLights.dynamicCount())==1,"Held soul torch is visibly blue");
c.runOnClient(m->{var l=DynamicLightClient.snapshot(m.level).sources().iterator().next();check((l.color()&255)>(l.color()>>16&255),"Dynamic source has actual blue colour");});
c.runOnClient(m->m.options.setCameraType(net.minecraft.client.CameraType.THIRD_PERSON_BACK));c.waitTicks(25);
c.runOnClient(m->{var pos=ColoredLights.dynamicPosition(m.player.getId());check(pos!=null&&pos.distanceToSqr(m.player.getEyePosition())<1,"Third-person light follows player, not camera");});capture(c,"held-third-person");
c.runOnClient(m->m.options.setCameraType(net.minecraft.client.CameraType.FIRST_PERSON));c.waitTicks(20);settle(c);
int builds=c.computeOnClient(m->ColoredLights.builds());
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.setItemSlot(EquipmentSlot.MAINHAND,ItemStack.EMPTY);p.setItemSlot(EquipmentSlot.OFFHAND,new ItemStack(Items.SOUL_TORCH));});c.waitTicks(35);settle(c);check(c.computeOnClient(m->ColoredLights.builds())==builds,"Moving a light between hands does not rebuild static volume");capture(c,"offhand-soul-blue");
var copperItem=BuiltInRegistries.ITEM.getValue(Identifier.parse("minecraft:copper_torch"));
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.setItemSlot(EquipmentSlot.OFFHAND,ItemStack.EMPTY);AccessoryAccess.of(p).setItem(AccessoryContainer.HEAD,new ItemStack(copperItem));AccessoryService.publish(p);});c.waitTicks(45);settle(c);
c.runOnClient(m->{var l=DynamicLightClient.snapshot(m.level).sources().iterator().next();check((l.color()>>8&255)>(l.color()>>16&255)&&(l.color()>>8&255)>(l.color()&255),"Head cosmetic carries copper green");});capture(c,"head-copper-green");check(c.computeOnClient(m->ColoredLights.builds())==builds,"Dynamic colour updates preserve static cache");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();AccessoryAccess.of(p).setItem(AccessoryContainer.HEAD,ItemStack.EMPTY);AccessoryService.publish(p);});c.waitFor(m->DynamicLightClient.snapshot(m.level).size()==0&&ColoredLights.dynamicCount()==0,200);c.waitTicks(25);
server.runOnServer(s->command(s,"gamemode spectator @a"));
camera(c,server,100.5,108,104.5,70);look(c,server,0,18);c.waitTicks(25);
server.runOnServer(s->{for(int x=88;x<=112;x++)for(int z=117;z<=155;z++)s.overworld().setBlockAndUpdate(new BlockPos(x,100,z),Blocks.CONCRETE.white().defaultBlockState());
for(int x=88;x<=112;x++)for(int y=101;y<=109;y++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,116),Blocks.AIR.defaultBlockState());
s.overworld().setBlockAndUpdate(new BlockPos(100,101,140),Blocks.VERDANT_FROGLIGHT.defaultBlockState());});c.waitTicks(50);
c.runOnClient(m->VanillaLight.setColoredLightDistance(8));settle(c);var shortRange=capture(c,"distance-8");int small=c.computeOnClient(m->ColoredLights.volumeSide());check(c.computeOnClient(m->ColoredLights.sample(new BlockPos(100,101,141)))==0,"Far field omitted at short distance");
int before=c.computeOnClient(m->ColoredLights.builds());c.runOnClient(m->VanillaLight.setColoredLightDistance(64));c.waitFor(m->ColoredLights.builds()>before&&ColoredLights.blend()>0&&ColoredLights.blend()<1,1600);var transition=capture(c,"distance-fade");settle(c);var longRange=capture(c,"distance-64");
check(c.computeOnClient(m->ColoredLights.volumeSide())>small,"Distance changes actual field allocation");check((c.computeOnClient(m->ColoredLights.sample(new BlockPos(100,101,141)))&0xffffff)!=0,"Far light retained at long distance");check(difference(shortRange,longRange)>1000,"Far light is visibly rendered");check(difference(transition,longRange)>100&&difference(shortRange,transition)<difference(shortRange,longRange),"Transition is an intermediate rendered frame without overshoot");
for(int distance:new int[]{8,16,32,64}){c.runOnClient(m->{VanillaLight.setColoredLightDistance(distance);forgetPreferences();check(VanillaLight.coloredLightDistance()==distance,"Persisted distance "+distance);});settle(c);}
c.runOnClient(m->{for(String locale:new String[]{"fr_fr","en_us"}){m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());for(String key:new String[]{"warm_torches","warm_torches_help","colored_light_distance","colored_light_distance_help","colored_light_distance_value"}){String full="sanctuary.shaders."+key;check(!Component.translatable(full,16).getString().equals(full),"Translated "+locale+" "+key);}}});
System.out.println("COLORED140_PASS accent="+accent+" native/warm torches, copper green, saturated red, main/offhand/head, dynamic cache, 8/16/32/64 distance, fade and FR/EN");
}
private static void source(ClientGameTestContext c,TestServerContext s,Block b){s.runOnServer(server->server.overworld().setBlockAndUpdate(SOURCE,b.defaultBlockState()));c.waitTicks(35);settle(c);}
private static void enable(ClientGameTestContext c,boolean value){c.runOnClient(m->VanillaLight.setColoredLights(value));if(value)settle(c);else c.waitTicks(10);}
private static void settle(ClientGameTestContext c){c.waitFor(m->ColoredLights.applied()&&ColoredLights.settled(),2000);c.waitTicks(12);}
private static long difference(Frame a,Frame b){long total=0;for(int i=0;i<a.pixels().length;i++)total+=channelDifference(a.pixels()[i],b.pixels()[i]);return total;}
}
@@ -0,0 +1,51 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.Blocks;
/** Real enclosed stone surfaces: local normal response without sunlight. */
final class DirectionalLights141ClientChecks {
private static final BlockPos LEFT=new BlockPos(96,101,102),RIGHT=new BlockPos(104,101,102),PROBE=new BlockPos(100,101,102);
static void run(ClientGameTestContext c,TestServerContext server){
c.runOnClient(m->{VanillaLight.setEnabled(true);VanillaLight.setSaturation(100);VanillaLight.setSsgi(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);VanillaLight.setLensFlare(false);VanillaLight.setEdgeHighlights(false);VanillaLight.setPixelShadows(false);VanillaLight.setSunshafts(false);m.options.particles().set(net.minecraft.server.level.ParticleStatus.MINIMAL);VanillaLight.setColoredLights(true);VanillaLight.setColoredLightIntensity(30);VanillaLight.setColoredLightDistance(16);VanillaLight.setWarmTorches(false);VanillaLight.setPbr(false);VanillaLight.setPbrIntensity(100);});
camera(c,server,100.5,101,98.5,70);look(c,server,0,35);c.waitTicks(30);
server.runOnServer(s->{if(s.clockManager().getInstance(s.overworld().dimensionType().defaultClock().orElseThrow()).totalTicks()!=18000)command(s,"time set 18000");
for(int x=88;x<=112;x++)for(int z=92;z<=116;z++)for(int y=100;y<=109;y++)
s.overworld().setBlockAndUpdate(new BlockPos(x,y,z),(y==100)?Blocks.STONE.defaultBlockState():(y==109||x==88||x==112||z==92||z==116)?Blocks.CONCRETE.white().defaultBlockState():Blocks.AIR.defaultBlockState());
s.overworld().setBlockAndUpdate(LEFT,Blocks.TORCH.defaultBlockState());
});c.waitTicks(40);settle(c);var flat=capture(c,"direction-left-pbr-off");
c.runOnClient(m->VanillaLight.setPbr(true));c.waitFor(m->ColoredLights.directionSample(PROBE)!=0&&ProceduralPbr.ready(),2000);settle(c);
int left=c.computeOnClient(m->ColoredLights.directionSample(PROBE));check(dx(left)<-.9&&Math.abs(dy(left))<.02&&Math.abs(dz(left))<.02,"Cached arrival points toward the left source");
var relief=capture(c,"direction-left-pbr-on");long delta=difference(flat,relief);check(delta>1000,"Local light visibly affects stone normals without skylight: "+delta);
int builds=c.computeOnClient(m->ColoredLights.builds());c.waitTicks(25);check(c.computeOnClient(m->ColoredLights.builds())==builds,"Static directions reuse the cache");unchanged(relief,capture(c,null),"Static local PBR is stable");
if(Boolean.getBoolean("sanctuary.test.pbr142"))PbrRefresh142ClientChecks.refresh(c,server,relief);
server.runOnServer(s->{s.overworld().setBlockAndUpdate(LEFT,Blocks.AIR.defaultBlockState());s.overworld().setBlockAndUpdate(RIGHT,Blocks.TORCH.defaultBlockState());});c.waitTicks(40);settle(c);
int right=c.computeOnClient(m->ColoredLights.directionSample(PROBE));check(dx(right)>.9,"Moving the source reverses arrival direction");capture(c,"direction-right-pbr-on");
server.runOnServer(s->s.overworld().setBlockAndUpdate(LEFT,Blocks.TORCH.defaultBlockState()));c.waitTicks(40);settle(c);
int opposed=c.computeOnClient(m->ColoredLights.directionSample(PROBE));check(Math.abs(dx(opposed))<.02&&Math.abs(dy(opposed))<.02&&Math.abs(dz(opposed))<.02&&(opposed>>>24)>0,"Equal opposing lights cancel direction, retain illumination");capture(c,"direction-opposed");
// A sealed barrier must prevent both intensity and direction reaching the probe.
server.runOnServer(s->{s.overworld().setBlockAndUpdate(RIGHT,Blocks.AIR.defaultBlockState());for(int y=101;y<109;y++)for(int z=93;z<116;z++)s.overworld().setBlockAndUpdate(new BlockPos(99,y,z),Blocks.STONE.defaultBlockState());});c.waitTicks(40);settle(c);
check(c.computeOnClient(m->ColoredLights.directionSample(PROBE))==0,"Closed wall blocks local direction and energy");capture(c,"direction-blocked");
server.runOnServer(s->{s.overworld().setBlockAndUpdate(LEFT,Blocks.AIR.defaultBlockState());
for(int y=101;y<109;y++)for(int z=93;z<116;z++)s.overworld().setBlockAndUpdate(new BlockPos(99,y,z),Blocks.AIR.defaultBlockState());
command(s,"gamemode creative @a");s.getPlayerList().getPlayers().getFirst().setItemSlot(net.minecraft.world.entity.EquipmentSlot.MAINHAND,new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.TORCH));});
c.waitTicks(45);settle(c);capture(c,"direction-held-pbr-on");
c.runOnClient(m->VanillaLight.setPbr(false));c.waitTicks(35);settle(c);var heldFlat=capture(c,"direction-held-pbr-off");
c.runOnClient(m->VanillaLight.setPbr(true));c.waitTicks(35);settle(c);var heldRelief=capture(c,"direction-held-pbr-on-final");
if(Boolean.getBoolean("sanctuary.test.pbr142"))PbrRefresh142ClientChecks.held(c,server);
long heldDelta=difference(heldFlat,heldRelief);check(heldDelta>1000&&c.computeOnClient(m->ColoredLights.dynamicCount())==1,"Carried light drives local PBR: "+heldDelta);
server.runOnServer(s->s.getPlayerList().getPlayers().getFirst().setItemSlot(net.minecraft.world.entity.EquipmentSlot.MAINHAND,net.minecraft.world.item.ItemStack.EMPTY));c.waitTicks(35);
c.runOnClient(m->VanillaLight.setPbr(false));c.waitTicks(35);settle(c);check(c.computeOnClient(m->ColoredLights.directionSample(LEFT))==0,"PBR OFF releases directional field");
c.runOnClient(m->{VanillaLight.setColoredLights(false);VanillaLight.setPbr(true);});c.waitTicks(25);capture(c,"direction-colored-off");
check(!c.computeOnClient(m->ColoredLights.allocated()),"Color OFF releases local field while PBR keeps its solar path");
if(Boolean.getBoolean("sanctuary.test.pbr142"))PbrRefresh142ClientChecks.cave(c,server);
System.out.println("DIRECTIONAL141_PASS localPbrDelta="+delta+" native normals, left/right, opposing sources, wall, held light, stable cache, OFF");
}
private static double dx(int c){return ((c>>16&255)-128)/127.;}
private static double dy(int c){return ((c>>8&255)-128)/127.;}
private static double dz(int c){return ((c&255)-128)/127.;}
private static void settle(ClientGameTestContext c){c.waitFor(m->ColoredLights.applied()&&ColoredLights.settled(),2400);c.waitTicks(14);}
private static long difference(Frame a,Frame b){long n=0;for(int i=0;i<a.pixels().length;i++)n+=channelDifference(a.pixels()[i],b.pixels()[i]);return n;}
}
@@ -13,7 +13,7 @@ import java.util.Arrays;
public final class Pbr134ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){new PixelShadows122ClientChecks(false,false,true).runTest(c);}
static void run(ClientGameTestContext c,TestServerContext server){
c.runOnClient(m->{preferences();VanillaLight.setSunshafts(false);VanillaLight.setLensFlare(false);materials();VanillaLight.setEnabled(true);VanillaLight.setSaturation(100);VanillaLight.setPbr(false);VanillaLight.setSsgi(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);VanillaLight.setEdgeHighlights(false);VanillaLight.setPixelShadows(false);});
c.runOnClient(m->{preferences();VanillaLight.setSunshafts(false);VanillaLight.setLensFlare(false);materials();VanillaLight.setEnabled(true);VanillaLight.setSaturation(100);VanillaLight.setColoredLights(false);VanillaLight.setPbr(false);VanillaLight.setSsgi(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);VanillaLight.setEdgeHighlights(false);VanillaLight.setPixelShadows(false);});
server.runOnServer(s->{command(s,"time set 1000");for(int x=-5;x<=24;x++)for(int z=-5;z<=24;z++){
s.overworld().setBlockAndUpdate(new BlockPos(x,64,z),(x<10?Blocks.BRICKS:x<17?Blocks.OAK_PLANKS:Blocks.IRON_BLOCK).defaultBlockState());
for(int y=65;y<=102;y++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,z),Blocks.AIR.defaultBlockState());
@@ -61,7 +61,7 @@ public final class Pbr134ClientChecks implements FabricClientGameTest {
}
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()==50&&VanillaLight.ssgi()&&!VanillaLight.bloom(),"Missing PBR setting defaults ON while preserving explicit old choices");
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");}
VanillaLight.setPbrIntensity(50);
}catch(Exception e){throw new AssertionError(e);}}
@@ -0,0 +1,63 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.item.*;
import net.minecraft.world.entity.EquipmentSlot;
final class PbrRefresh142ClientChecks {
static void refresh(ClientGameTestContext c,TestServerContext s,Frame reference){
int entries=c.computeOnClient(m->ProceduralPbr.terrainEntries()),builds=c.computeOnClient(m->ProceduralPbr.terrainBuilds());check(entries>0,"PBR fixture has visible sections");
for(int i=0;i<12;i++){
c.runOnClient(m->{for(var section:m.levelRenderer.visibleSections()){var p=section.getRenderOrigin();ProceduralPbr.dirtySection(p.getX()>>4,p.getY()>>4,p.getZ()>>4);}
check(ProceduralPbr.terrainEntries()==entries,"Invalidation retains every published PBR section");});
c.waitTicks(1);unchanged(reference,capture(c,null),"PBR remains visible throughout repeated refreshes");
}
ready(c);check(c.computeOnClient(m->ProceduralPbr.terrainBuilds())>builds,"Replacement meshes actually published");unchanged(reference,capture(c,"refresh-stable"),"Replacement preserves the rendered normals");
System.out.println("PBR142_REFRESH_PASS entries="+entries+" repeated invalidation, retained rendering, completed replacements");
}
static void held(ClientGameTestContext c,TestServerContext s){
ready(c);int builds=c.computeOnClient(m->ProceduralPbr.terrainBuilds());
for(var item:new Item[]{Items.SOUL_TORCH,Items.TORCH,Items.SOUL_TORCH,Items.TORCH}){
s.runOnServer(server->server.getPlayerList().getPlayers().getFirst().setItemSlot(EquipmentSlot.MAINHAND,new ItemStack(item)));c.waitTicks(25);
}
check(c.computeOnClient(m->ProceduralPbr.terrainBuilds())==builds,"Carried light updates do not rebuild the normal mesh");
System.out.println("PBR142_HELD_PASS native dynamic relighting preserves the PBR geometry cache");
}
static void cave(ClientGameTestContext c,TestServerContext s){
c.runOnClient(m->{VanillaLight.setColoredLights(true);VanillaLight.setPbr(false);VanillaLight.setPbrIntensity(80);VanillaLight.setColoredLightDistance(16);});
camera(c,s,0.5,65,2.5,70);look(c,s,0,-12);c.waitTicks(30);
s.runOnServer(server->{for(int x=-5;x<=5;x++)for(int z=-1;z<=18;z++)for(int y=64;y<=70;y++)
server.overworld().setBlockAndUpdate(new BlockPos(x,y,z),(x==-5||x==5||y==64||y==70||z==-1||z==18)?Blocks.DEEPSLATE.defaultBlockState():Blocks.AIR.defaultBlockState());
for(int y=65;y<=69;y++)server.overworld().setBlockAndUpdate(new BlockPos(-2,y,9),Blocks.DEEPSLATE.defaultBlockState());
for(int z=5;z<14;z++)server.overworld().setBlockAndUpdate(new BlockPos(3,67,z),Blocks.DEEPSLATE.defaultBlockState());
server.overworld().setBlockAndUpdate(new BlockPos(3,68,8),Blocks.WATER.defaultBlockState());
server.getPlayerList().getPlayers().getFirst().setItemSlot(EquipmentSlot.MAINHAND,new ItemStack(Items.TORCH));
});c.waitTicks(100);var off=capture(c,"cave-pbr-off");c.runOnClient(m->VanillaLight.setPbr(true));ready(c);var on=capture(c,"cave-pbr-80");
check(specks(on)<=specks(off)+4,"PBR does not introduce isolated white points in the cave");
System.out.println("PBR142_CAVE_PIXELS off="+specks(off)+" on="+specks(on));
// Capture while the camera is actually travelling, before settling caches.
s.runOnServer(server->{var player=server.getPlayerList().getPlayers().getFirst();player.getAbilities().flying=true;player.onUpdateAbilities();});c.waitTicks(10);
var start=c.computeOnClient(m->m.player.position());
c.getInput().holdKey(o->o.keyUp);
int maximumSpecks=0;
try {for(int frame=0;frame<24;frame++){
c.waitTicks(1);c.runOnClient(m->check(ColoredLights.dynamicCount()>0&&ProceduralPbr.applied(),"Carried light and PBR remain active during motion"));var moving=capture(c,frame%8==0?"cave-moving-"+frame:null);
maximumSpecks=Math.max(maximumSpecks,specks(moving));
}} finally {c.getInput().releaseKey(o->o.keyUp);}
check(c.computeOnClient(m->m.player.position().distanceToSqr(start))>1.0,"Camera really travels at least one block");
check(maximumSpecks<=4,"Moving camera has no isolated white grain: "+maximumSpecks);
System.out.println("PBR142_MOVEMENT_PASS frames=24 maxSpecks="+maximumSpecks);
camera(c,s,0.5,65,2.5,70);
for(float pitch:new float[]{-30,0,35}){look(c,s,0,pitch);ready(c);var frame=capture(c,"cave-angle-"+(int)pitch);check(specks(frame)<=4,"Grazing-angle PBR has no white grain");System.out.println("PBR142_CAVE_ANGLE "+pitch+" specks="+specks(frame));}
c.runOnClient(m->{VanillaLight.setSsgi(true);VanillaLight.setBloom(true);VanillaLight.setPixelShadows(true);VanillaLight.setEdgeHighlights(true);VanillaLight.setSunshafts(true);});c.waitTicks(40);ready(c);capture(c,"cave-combined");
System.out.println("PBR142_PASS refresh, dynamic cache, defaults, cave and grazing-angle captures");
}
private static int specks(Frame f){int count=0,w=f.width();for(int y=2;y<f.height()-2;y++)for(int x=2;x<w-2;x++){
int i=y*w+x,p=f.pixels()[i];int value=Math.min(p>>16&255,Math.min(p>>8&255,p&255));if(value<170)continue;
int dark=0;for(int dy=-1;dy<=1;dy++)for(int dx=-1;dx<=1;dx++){if(dx==0&&dy==0)continue;int q=f.pixels()[i+dy*w+dx];if(Math.max(q>>16&255,Math.max(q>>8&255,q&255))<120)dark++;}if(dark>=5)count++;
}return count;}
private static void ready(ClientGameTestContext c){c.waitTicks(14);c.waitFor(m->ProceduralPbr.ready(),2400);c.waitTicks(8);}
}
@@ -54,7 +54,9 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
c.runOnClient(m -> {
var info = com.mojang.blaze3d.systems.RenderSystem.getDevice().getDeviceInfo();
String expected = System.getProperty("sanctuary.test.graphicsBackend", "");
String expected = System.getProperty("sanctuary.test.graphicsBackend", "vulkan");
check(net.minecraft.client.PreferredGraphicsApi.DEFAULT.getBackendsToTry()[0] instanceof com.mojang.renderpearl.backend.vulkan.VulkanBackend, "Default tries native Vulkan first");
check(net.minecraft.client.PreferredGraphicsApi.OPENGL.getBackendsToTry()[0] instanceof com.mojang.renderpearl.backend.opengl.GlBackend, "Explicit OpenGL remains available");
check(expected.isEmpty() || info.backendName().equalsIgnoreCase(expected),
"Requested graphics backend must not silently fall back: " + expected + " / " + info.backendName());
System.out.println("SHADER139_BACKEND " + info.backendName() + " / " + info.name()
@@ -149,7 +151,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
c.runOnClient(m -> check(VanillaLight.active(), "Native Sanctuary shader is active without an external engine"));
float morning = c.computeOnClient(m -> m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle);
if(coloredOnly){ColoredLights139ClientChecks.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(pbrOnly){Pbr134ClientChecks.run(c,server);return;}
@@ -281,7 +283,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
});
});
c.waitTicks(3);
c.takeScreenshot("sanctuary-beta139-shader-options-" + locale);
c.takeScreenshot("sanctuary-beta142-shader-options-" + locale);
}
c.runOnClient(m -> m.gui.setScreen(null));
}
@@ -450,11 +452,12 @@ 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()==50&&VanillaLight.ssgiIntensity()==35,"Existing effect strengths retained");
check(VanillaLight.pbrIntensity()==80&&VanillaLight.ssgiIntensity()==35,"PBR default 80, 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();
check(!VanillaLight.bloom()&&VanillaLight.bloomIntensity()==20&&VanillaLight.pbr()&&VanillaLight.ssgi(),"Missing fields get new defaults without replacing explicit values");
Files.writeString(path,"{\"pbrIntensity\":50}");forgetPreferences();check(VanillaLight.pbrIntensity()==50,"Explicit PBR 50 is preserved");
System.out.println("SHADER135_DEFAULTS_PASS fresh, partial and personal preferences");
} catch(IOException e){throw new AssertionError(e);}
}
@@ -565,7 +568,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
}
static Frame capture(ClientGameTestContext c, String name) {
if (name != null) c.takeScreenshot("sanctuary-beta139-shader-" + name);
if (name != null) c.takeScreenshot("sanctuary-beta142-shader-" + name);
var result = new CompletableFuture<Frame>();
c.runOnClient(m -> Screenshot.takeScreenshot(m.gameRenderer.mainRenderTarget(), image -> {
try (image) {
@@ -13,7 +13,7 @@ import java.nio.file.Files;
public final class Ssgi132ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){new PixelShadows122ClientChecks(false,true).runTest(c);}
static void run(ClientGameTestContext c,TestServerContext server){
c.runOnClient(m->{preferences();VanillaLight.setSunshafts(false);VanillaLight.setLensFlare(false);VanillaLight.setPbr(false);VanillaLight.setEnabled(true);VanillaLight.setSaturation(100);VanillaLight.setSsgi(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);VanillaLight.setPixelShadows(false);VanillaLight.setEdgeHighlights(false);});
c.runOnClient(m->{preferences();VanillaLight.setSunshafts(false);VanillaLight.setLensFlare(false);VanillaLight.setPbr(false);VanillaLight.setEnabled(true);VanillaLight.setSaturation(100);VanillaLight.setColoredLights(false);VanillaLight.setSsgi(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);VanillaLight.setPixelShadows(false);VanillaLight.setEdgeHighlights(false);});
server.runOnServer(s->{command(s,"time set 6000");for(int x=-5;x<=24;x++)for(int z=-5;z<=24;z++){
s.overworld().setBlockAndUpdate(new BlockPos(x,64,z),Blocks.CONCRETE.white().defaultBlockState());
for(int y=65;y<=102;y++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,z),Blocks.AIR.defaultBlockState());
@@ -10,7 +10,7 @@ import net.minecraft.world.level.block.Blocks;
public final class SsgiCloseup135ClientChecks implements net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest {
public void runTest(ClientGameTestContext c){new PixelShadows122ClientChecks(false,false,false,true).runTest(c);}
static void run(ClientGameTestContext c,TestServerContext server){
c.runOnClient(m->{VanillaLight.setEnabled(true);VanillaLight.setSaturation(100);VanillaLight.setSsgi(false);VanillaLight.setPbr(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);VanillaLight.setPixelShadows(false);VanillaLight.setEdgeHighlights(false);});
c.runOnClient(m->{VanillaLight.setEnabled(true);VanillaLight.setSaturation(100);VanillaLight.setColoredLights(false);VanillaLight.setSsgi(false);VanillaLight.setPbr(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);VanillaLight.setPixelShadows(false);VanillaLight.setEdgeHighlights(false);});
server.runOnServer(s->{
// Minecraft 26.3 rejects setting a clock to its existing value.
command(s,"time add 1");command(s,"time set 6000");
@@ -27,6 +27,15 @@ public final class DynamicLightClient {
private static DynamicLightConfig config;
private static final Map<Item,Integer> overrides=new IdentityHashMap<>();
private static int ticks;
private static boolean invalidatingSections;
private static final fr.koka.sanctuary.client.shader.ColoredLightMaterials colors=new fr.koka.sanctuary.client.shader.ColoredLightMaterials();
private static Object models;
public static boolean invalidatingSections(){return invalidatingSections;}
private static final class ColorMix {
double r,g,b;
void add(int light,int color){if(light<=0)return;double energy=Math.pow(.8,15-light);r=Math.max(r,(color>>16&255)*energy);g=Math.max(g,(color>>8&255)*energy);b=Math.max(b,(color&255)*energy);}
int result(){double m=Math.max(1e-8,Math.max(r,Math.max(g,b)));return (int)Math.round(r/m*255)<<16|(int)Math.round(g/m*255)<<8|(int)Math.round(b/m*255);}
}
private DynamicLightClient() {}
public static void register(KeyMapping.Category category) {
config=DynamicLightConfig.load();
@@ -50,7 +59,7 @@ public final class DynamicLightClient {
}
public static int pendingSections() {return field.pendingCount();}
private static void reset(ClientLevel level) {
field=new DynamicLightField();view=new View(level,field.snapshot());ticks=0;
field=new DynamicLightField();view=new View(level,field.snapshot());ticks=0;colors.clear();models=null;
}
private static int emission(ItemStack stack) {return overrides.getOrDefault(stack.getItem(),-1)>=0?overrides.get(stack.getItem()):ItemLightEmission.of(stack);}
private static void tick(Minecraft m) {
@@ -59,6 +68,8 @@ public final class DynamicLightClient {
if(ticks++%config.updateIntervalTicks==0) {
List<DynamicLightField.Source> sources=List.of();
if(config.enabled) {
var currentModels=m.getModelManager().getBlockStateModelSet();if(models!=currentModels){colors.clear();models=currentModels;}
boolean tinted=fr.koka.sanctuary.client.shader.VanillaLight.coloredLights();
var eye=m.getCameraEntity().getEyePosition();
var collect=new DynamicLightField.Collector(config.dropClusterSize);
double distance=config.range*config.range;
@@ -66,23 +77,26 @@ public final class DynamicLightClient {
if(entity.isRemoved() || entity.distanceToSqr(eye)>distance)continue;
boolean drop=entity instanceof ItemEntity;
int light=entity.isOnFire()?15:0;
var mix=new ColorMix();if(light>0)mix.add(light,0xFFAB55);
double y=entity.getY()+entity.getBbHeight()*.5;
if(entity instanceof ItemEntity item)light=Math.max(light,emission(item.getItem()));
if(entity instanceof ItemEntity item){int emitted=emission(item.getItem());light=Math.max(light,emitted);if(emitted>0&&tinted)mix.add(emitted,colors.color(item.getItem()));}
else if(entity instanceof LivingEntity living) {
if(living instanceof Player p && p.isSpectator())continue;
light=Math.max(light,Math.max(emission(living.getMainHandItem()),emission(living.getOffhandItem())));
light=Math.max(light,emission(living.getItemBySlot(EquipmentSlot.HEAD)));
light=Math.max(light,emission(HeadCosmeticService.appearance(living)));
for(var stack:new ItemStack[]{living.getMainHandItem(),living.getOffhandItem(),living.getItemBySlot(EquipmentSlot.HEAD),HeadCosmeticService.appearance(living)}){
int emitted=emission(stack);light=Math.max(light,emitted);if(emitted>0&&tinted)mix.add(emitted,colors.color(stack));
}
y=entity.getEyeY();
}
if(light>0)collect.add(entity.getId(),drop,entity.getX(),y,entity.getZ(),light);
if(light>0)collect.add(entity.getId(),drop,entity.getX(),y,entity.getZ(),light,tinted?mix.result():0xFFFFFF);
}
sources=collect.finish(config.maxSources,eye.x,eye.y,eye.z,m.player==null?-1:m.player.getId());
}
if(field.update(sources))view=new View(m.level,field.snapshot());
}
// Native renderer marks only resident sections; it neither loads chunks nor rebuilds the whole world.
field.drain(config.sectionUpdatesPerTick,key->m.levelExtractor.setSectionDirty(
DynamicLightField.sectionX(key),DynamicLightField.sectionY(key),DynamicLightField.sectionZ(key)));
invalidatingSections=true;
try{field.drain(config.sectionUpdatesPerTick,key->m.levelExtractor.setSectionDirty(
DynamicLightField.sectionX(key),DynamicLightField.sectionY(key),DynamicLightField.sectionZ(key)));}
finally{invalidatingSections=false;}
}
}
@@ -0,0 +1,37 @@
package fr.koka.sanctuary.client.shader;
import fr.koka.sanctuary.client.DynamicLightClient;
import fr.koka.sanctuary.lighting.DynamicLightField;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.world.phys.Vec3;
/** Same bounded sources as native dynamic lighting, with visual interpolation only. */
final class ColoredDynamicLights {
static final int LIMIT=128;
static final class Light {
double x,y,z,r,g,b,visibility;int level;boolean seen;
Light(DynamicLightField.Source s){x=s.x();y=s.y();z=s.z();level=s.light();r=(s.color()>>16&255)/255.;g=(s.color()>>8&255)/255.;b=(s.color()&255)/255.;}
}
private final Map<DynamicLightField.Id,Light> lights=new HashMap<>();
private long last;
List<Light> update(Vec3 camera){
long now=System.nanoTime();double dt=last==0?0:Math.min(.1,(now-last)/1e9);last=now;
double ease=1-Math.exp(-dt*14),fade=dt/.3;
lights.values().forEach(l->l.seen=false);
var mc=Minecraft.getInstance();
for(var s:DynamicLightClient.snapshot(mc.level).sources()){
Light l=lights.computeIfAbsent(s.id(),id->new Light(s));l.seen=true;l.level=s.light();
boolean own=!s.id().drop()&&mc.player!=null&&s.id().value()==mc.player.getId()
&&mc.getCameraEntity()==mc.player&&mc.options.getCameraType().isFirstPerson();
double x=own?camera.x:s.x(),y=own?camera.y:s.y(),z=own?camera.z:s.z();
double step=own?1:ease;l.x+=(x-l.x)*step;l.y+=(y-l.y)*step;l.z+=(z-l.z)*step;
l.r+=((s.color()>>16&255)/255.-l.r)*ease;l.g+=((s.color()>>8&255)/255.-l.g)*ease;l.b+=((s.color()&255)/255.-l.b)*ease;
}
for(var it=lights.values().iterator();it.hasNext();){var l=it.next();l.visibility=Math.clamp(l.visibility+(l.seen?fade:-fade),0,1);if(!l.seen&&l.visibility<=0)it.remove();}
return lights.values().stream().filter(l->camera.distanceToSqr(l.x,l.y,l.z)<Math.pow(VanillaLight.coloredLightDistance()+l.level,2))
.sorted(Comparator.comparingDouble(l->camera.distanceToSqr(l.x,l.y,l.z))).limit(LIMIT).toList();
}
Vec3 position(long entity){var l=lights.get(new DynamicLightField.Id(false,entity));return l==null?null:new Vec3(l.x,l.y,l.z);}
void clear(){lights.clear();last=0;}
}
@@ -10,15 +10,30 @@ import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.client.renderer.block.dispatch.BlockStateModelPart;
/** Colours are analysed once per state/resource reload, never from rendered GPU pixels. */
final class ColoredLightMaterials {
public final class ColoredLightMaterials {
private final Map<BlockState,Integer> colors=new IdentityHashMap<>();
private final Map<Identifier,Integer> sprites=new HashMap<>();
int color(BlockState state){return colors.computeIfAbsent(state,this::analyse);}
private boolean warm;
public int color(BlockState state){if(warm!=VanillaLight.warmTorches()){warm=VanillaLight.warmTorches();clear();}return colors.computeIfAbsent(state,this::analyse);}
public int color(net.minecraft.world.item.ItemStack stack){
var state=fr.koka.sanctuary.cosmetics.HeadCosmeticData.state(stack);
if(state!=null)return color(state);
var item=stack.getItem();
if(item==net.minecraft.world.item.Items.LAVA_BUCKET)return 0xFF6020;
if(item==net.minecraft.world.item.Items.BLAZE_ROD||item==net.minecraft.world.item.Items.BLAZE_POWDER)return 0xFFAB55;
if(item==net.minecraft.world.item.Items.GLOWSTONE_DUST)return color(net.minecraft.world.level.block.Blocks.GLOWSTONE.defaultBlockState());
if(item==net.minecraft.world.item.Items.GLOW_BERRIES)return 0xFFBD52;
if(item==net.minecraft.world.item.Items.GLOW_INK_SAC||item==net.minecraft.world.item.Items.GLOW_ITEM_FRAME)return 0x66EADD;
return 0xFFFFFF;
}
private int analyse(BlockState state){
var id=BuiltInRegistries.BLOCK.getKey(state.getBlock());String name=id.getPath();
if(id.getNamespace().equals("minecraft")){
if(name.contains("copper"))return 0x62FFA0;
if(name.startsWith("soul_"))return 0x55BDFF;
if(name.contains("redstone"))return 0xFF3020;
if(name.contains("redstone"))return 0xFF0602;
if(name.equals("torch")||name.equals("wall_torch"))return VanillaLight.warmTorches()?0xFFAB55:0xFFFFFF;
if(name.equals("lava"))return 0xFF6020;
if(name.equals("pearlescent_froglight"))return 0xFFC0EC;
if(name.equals("verdant_froglight"))return 0xB4FF82;
if(name.equals("ochre_froglight"))return 0xFFCD72;
@@ -53,6 +68,6 @@ final class ColoredLightMaterials {
float max=Math.max(1,Math.max(r,Math.max(g,b)));
return Math.round(r/max*255)<<16|Math.round(g/max*255)<<8|Math.round(b/max*255);
}
void clear(){colors.clear();sprites.clear();}
public void clear(){colors.clear();sprites.clear();}
int cachedStates(){return colors.size();}
}
@@ -15,102 +15,147 @@ import net.minecraft.world.level.chunk.status.ChunkStatus;
/** Client-only RGB propagation. Snapshot, flood and packing share a bounded per-frame work budget. */
final class ColoredLightVolume implements AutoCloseable {
static final int SIDE=64,COUNT=SIDE*SIDE*SIDE,ATLAS=512;
static int sideFor(int distance){return (distance+20)*2;}
static int columns(int side){return (int)Math.ceil(Math.sqrt(side));}
private static final Direction[] DIRECTIONS=Direction.values();
private final ColoredLightMaterials materials=new ColoredLightMaterials();
private ClientLevel level;
private Object models;
private Build build;
private DynamicTexture texture;
private DynamicTexture texture,previous,direction,previousDirection;
private boolean directional;
DynamicTexture direction(){return direction;}
DynamicTexture previousDirection(){return previousDirection;}
private BlockPos previousOrigin;
private int side,previousSide;
private long transitionStarted;
float blend(){return Math.clamp((System.nanoTime()-transitionStarted)/300_000_000f,0,1);}
DynamicTexture previous(){return previous;}
BlockPos previousOrigin(){return previousOrigin;}
int previousSide(){return previousSide;}
int side(){return side;}
private BlockPos origin;
private long revision,published=-1,lastStart;
private int completed;
private boolean warm;
void update(){
var mc=Minecraft.getInstance();var modelSet=mc.getModelManager().getBlockStateModelSet();
if(level!=mc.level||models!=modelSet){close();level=mc.level;models=modelSet;}
if(warm!=VanillaLight.warmTorches()){warm=VanillaLight.warmTorches();materials.clear();revision++;if(build!=null){build.close();build=null;}}
var p=mc.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.pos;
var wanted=new BlockPos(anchor(p.x),anchor(p.y),anchor(p.z));
boolean wantedDirection=VanillaLight.pbr()&&VanillaLight.pbrIntensity()>0;
int wantedSide=sideFor(VanillaLight.coloredLightDistance());
var wanted=new BlockPos(anchor(p.x,wantedSide),anchor(p.y,wantedSide),anchor(p.z,wantedSide));
long now=System.nanoTime();
if(previous!=null&&blend()>=1){previous.close();previous=null;previousOrigin=null;if(previousDirection!=null){previousDirection.close();previousDirection=null;}}
// Complete a snapshot even if the world keeps changing, then catch up; moving
// beyond it cancels immediately. Static worlds do no further scanning or flooding.
if(build!=null&&!inside(build.origin,BlockPos.containing(p))){build.close();build=null;}
if(build==null&&(texture==null||!wanted.equals(origin)||published!=revision)&&now-lastStart>=100_000_000L){
build=new Build(level,wanted,revision,materials);lastStart=now;
if(build!=null&&(build.directional!=wantedDirection||build.side!=wantedSide||!inside(build.origin,BlockPos.containing(p),build.side))){build.close();build=null;}
if(build==null&&previous==null&&(texture==null||directional!=wantedDirection||side!=wantedSide||!wanted.equals(origin)||published!=revision)&&now-lastStart>=100_000_000L){
build=new Build(level,wanted,wantedSide,revision,materials,wantedDirection);lastStart=now;
}
if(build!=null&&build.advance(now+2_000_000L)){
if(texture==null)texture=new DynamicTexture(()->"Sanctuary coloured light volume",build.takeImage());
else {texture.setPixels(build.takeImage());texture.upload();}
previous=texture;previousOrigin=origin;previousSide=side;previousDirection=direction;
direction=build.directionImage==null?null:new DynamicTexture(()->"Sanctuary light direction",build.takeDirectionImage());directional=build.directional;
texture=new DynamicTexture(()->"Sanctuary coloured light volume",build.takeImage());
side=build.side;transitionStarted=System.nanoTime();
origin=build.origin;published=build.revision;build.close();build=null;completed++;
}
}
private static int anchor(double p){return Math.floorDiv((int)Math.floor(p),4)*4-SIDE/2;}
private static boolean inside(BlockPos base,BlockPos p){return p.getX()>=base.getX()&&p.getX()<base.getX()+SIDE&&p.getY()>=base.getY()&&p.getY()<base.getY()+SIDE&&p.getZ()>=base.getZ()&&p.getZ()<base.getZ()+SIDE;}
void dirty(BlockPos p){if(origin!=null&&inside(origin,p)||build!=null&&inside(build.origin,p))revision++;}
void dirtyChunk(int x,int z){if(overlaps(origin,x,z)||build!=null&&overlaps(build.origin,x,z))revision++;}
void dirtySection(int x,int y,int z){if(overlapsSection(origin,x,y,z)||build!=null&&overlapsSection(build.origin,x,y,z))revision++;}
private static boolean overlapsSection(BlockPos o,int x,int y,int z){return overlaps(o,x,z)&&y>=(o.getY()>>4)&&y<=((o.getY()+SIDE-1)>>4);}
private static boolean overlaps(BlockPos o,int x,int z){return o!=null&&x>=(o.getX()>>4)&&x<=((o.getX()+SIDE-1)>>4)&&z>=(o.getZ()>>4)&&z<=((o.getZ()+SIDE-1)>>4);}
private static int anchor(double p,int side){return Math.floorDiv((int)Math.floor(p),4)*4-side/2;}
private static boolean inside(BlockPos base,BlockPos p,int side){return p.getX()>=base.getX()&&p.getX()<base.getX()+side&&p.getY()>=base.getY()&&p.getY()<base.getY()+side&&p.getZ()>=base.getZ()&&p.getZ()<base.getZ()+side;}
void dirty(BlockPos p){if(origin!=null&&inside(origin,p,side)||build!=null&&inside(build.origin,p,build.side))revision++;}
void dirtyChunk(int x,int z){if(overlaps(origin,x,z,side)||build!=null&&overlaps(build.origin,x,z,build.side))revision++;}
void dirtySection(int x,int y,int z){if(overlapsSection(origin,x,y,z,side)||build!=null&&overlapsSection(build.origin,x,y,z,build.side))revision++;}
private static boolean overlapsSection(BlockPos o,int x,int y,int z,int side){return overlaps(o,x,z,side)&&y>=(o.getY()>>4)&&y<=((o.getY()+side-1)>>4);}
private static boolean overlaps(BlockPos o,int x,int z,int side){return o!=null&&x>=(o.getX()>>4)&&x<=((o.getX()+side-1)>>4)&&z>=(o.getZ()>>4)&&z<=((o.getZ()+side-1)>>4);}
DynamicTexture texture(){return texture;}
BlockPos origin(){return origin;}
boolean settled(){return texture!=null&&build==null&&revision==published;}
boolean settled(){return texture!=null&&build==null&&revision==published&&blend()>=1;}
int completed(){return completed;}
int directionSample(BlockPos p){if(direction==null||!inside(origin,p,side))return 0;int x=p.getX()-origin.getX(),y=p.getY()-origin.getY(),z=p.getZ()-origin.getZ(),cols=columns(side);return direction.getPixels().getPixel(x+(y%cols)*side,z+(y/cols)*side);}
int cachedStates(){return materials.cachedStates();}
int sample(BlockPos p){if(texture==null||!inside(origin,p))return 0;int x=p.getX()-origin.getX(),y=p.getY()-origin.getY(),z=p.getZ()-origin.getZ();return texture.getPixels().getPixel(x+(y&7)*SIDE,z+(y>>3)*SIDE);}
public void close(){if(build!=null){build.close();build=null;}if(texture!=null){texture.close();texture=null;}materials.clear();level=null;models=null;origin=null;published=-1;revision=0;lastStart=0;}
int sample(BlockPos p){if(texture==null||!inside(origin,p,side))return 0;int x=p.getX()-origin.getX(),y=p.getY()-origin.getY(),z=p.getZ()-origin.getZ(),cols=columns(side);return texture.getPixels().getPixel(x+(y%cols)*side,z+(y/cols)*side);}
public void close(){if(build!=null){build.close();build=null;}if(texture!=null){texture.close();texture=null;}if(previous!=null){previous.close();previous=null;}if(direction!=null){direction.close();direction=null;}if(previousDirection!=null){previousDirection.close();previousDirection=null;}materials.clear();level=null;models=null;origin=null;previousOrigin=null;side=previousSide=0;published=-1;revision=0;lastStart=0;}
private static final class Build implements AutoCloseable {
final int side,count,cols,chunkSide;
final ClientLevel level;final BlockPos origin;final long revision;final ColoredLightMaterials materials;
final BlockState[] states=new BlockState[COUNT];
final byte[] red=new byte[COUNT],green=new byte[COUNT],blue=new byte[COUNT],sky=new byte[COUNT];
final boolean[] queued=new boolean[COUNT];final IntArrayFIFOQueue queue=new IntArrayFIFOQueue();
final LevelChunk[] chunks=new LevelChunk[25];final int cx,cz;
final BlockState[] states;
final byte[] red,green,blue,sky;
final boolean[] queued;final IntArrayFIFOQueue queue=new IntArrayFIFOQueue();
final LevelChunk[] chunks;final int cx,cz;
final BlockPos.MutableBlockPos pos=new BlockPos.MutableBlockPos();
NativeImage image=new NativeImage(ATLAS,ATLAS,false);int scan,packed;
Build(ClientLevel level,BlockPos origin,long revision,ColoredLightMaterials materials){
NativeImage image,directionImage;final boolean directional;int scan,packed;
private static final double[] ENERGY=new double[256];
static{for(int i=129;i<256;i++)ENERGY[i]=Math.max(0,(Math.pow(.8,31-i/8.)-Math.pow(.8,15))/(1-Math.pow(.8,15)));}
Build(ClientLevel level,BlockPos origin,int side,long revision,ColoredLightMaterials materials,boolean directional){
this.directional=directional;this.side=side;count=side*side*side;cols=columns(side);chunkSide=(side+15)/16+1;
states=new BlockState[count];red=new byte[count];green=new byte[count];blue=new byte[count];sky=new byte[count];queued=new boolean[count];chunks=new LevelChunk[chunkSide*chunkSide];
image=new NativeImage(cols*side,((side+cols-1)/cols)*side,false);
if(directional)directionImage=new NativeImage(image.getWidth(),image.getHeight(),false);
this.level=level;this.origin=origin;this.revision=revision;this.materials=materials;
cx=origin.getX()>>4;cz=origin.getZ()>>4;
for(int z=0;z<5;z++)for(int x=0;x<5;x++)chunks[x+z*5]=level.getChunkSource().getChunk(cx+x,cz+z,ChunkStatus.FULL,false);
for(int z=0;z<chunkSide;z++)for(int x=0;x<chunkSide;x++)chunks[x+z*chunkSide]=level.getChunkSource().getChunk(cx+x,cz+z,ChunkStatus.FULL,false);
}
boolean advance(long deadline){
while(scan<COUNT){scan(scan++);if((scan&127)==0&&System.nanoTime()>=deadline)return false;}
while(scan<count){scan(scan++);if((scan&127)==0&&System.nanoTime()>=deadline)return false;}
int work=0;
while(!queue.isEmpty()){
int i=queue.dequeueInt();queued[i]=false;spread(i);
if((++work&127)==0&&System.nanoTime()>=deadline)return false;
}
while(packed<COUNT){
int i=packed++,x=i&63,z=i>>6&63,y=i>>12;
image.setPixel(x+(y&7)*SIDE,z+(y>>3)*SIDE,(sky[i]&255)<<24|(red[i]&255)<<16|(green[i]&255)<<8|(blue[i]&255));
while(packed<count){
int i=packed++,x=i%side,z=i/side%side,y=i/(side*side);
image.setPixel(x+(y%cols)*side,z+(y/cols)*side,(sky[i]&255)<<24|(red[i]&255)<<16|(green[i]&255)<<8|(blue[i]&255));
if(directional)directionImage.setPixel(x+(y%cols)*side,z+(y/cols)*side,directionAt(i,x,y,z));
if((packed&511)==0&&System.nanoTime()>=deadline)return false;
}
return true;
}
void scan(int i){
int x=origin.getX()+(i&63),z=origin.getZ()+(i>>6&63),y=origin.getY()+(i>>12);pos.set(x,y,z);
var chunk=chunks[((x>>4)-cx)+((z>>4)-cz)*5];
int x=origin.getX()+(i%side),z=origin.getZ()+(i/side%side),y=origin.getY()+(i/(side*side));pos.set(x,y,z);
var chunk=chunks[((x>>4)-cx)+((z>>4)-cz)*chunkSide];
var state=chunk==null||level.isOutsideBuildHeight(y)?Blocks.BEDROCK.defaultBlockState():chunk.getBlockState(pos);states[i]=state;
if(chunk!=null&&!level.isOutsideBuildHeight(y))sky[i]=(byte)(level.getBrightness(LightLayer.SKY,pos)*17);
int emission=state.getLightEmission();if(emission<=0)return;
int c=materials.color(state);red[i]=(byte)encode(emission,c>>16&255);green[i]=(byte)encode(emission,c>>8&255);blue[i]=(byte)encode(emission,c&255);enqueue(i);
}
void spread(int i){
int r=red[i]&255,g=green[i]&255,b=blue[i]&255;if(Math.max(r,Math.max(g,b))<=16)return;
int x=i&63,z=i>>6&63,y=i>>12;
int r=red[i]&255,g=green[i]&255,b=blue[i]&255;if(Math.max(r,Math.max(g,b))<=136)return;
int x=i%side,z=i/side%side,y=i/(side*side);
for(var d:DIRECTIONS){
int nx=x+d.getStepX(),ny=y+d.getStepY(),nz=z+d.getStepZ();if(nx<0||nx>=SIDE||ny<0||ny>=SIDE||nz<0||nz>=SIDE)continue;
int n=nx+(nz<<6)+(ny<<12);
if(r-16<=(red[n]&255)&&g-16<=(green[n]&255)&&b-16<=(blue[n]&255))continue;
int loss=net.minecraft.world.level.lighting.LightEngine.getLightDampeningInto(states[i],states[n],d,Math.max(1,states[n].getLightDampening()))*16;
int nr=Math.max(0,r-loss),ng=Math.max(0,g-loss),nb=Math.max(0,b-loss);boolean changed=false;
int nx=x+d.getStepX(),ny=y+d.getStepY(),nz=z+d.getStepZ();if(nx<0||nx>=side||ny<0||ny>=side||nz<0||nz>=side)continue;
int n=nx+nz*side+ny*side*side;
if(r-8<=(red[n]&255)&&g-8<=(green[n]&255)&&b-8<=(blue[n]&255))continue;
int loss=net.minecraft.world.level.lighting.LightEngine.getLightDampeningInto(states[i],states[n],d,Math.max(1,states[n].getLightDampening()))*8;
int nr=Math.max(0,r-loss),ng=Math.max(0,g-loss),nb=Math.max(0,b-loss);if(Math.max(nr,Math.max(ng,nb))<=128)continue;boolean changed=false;
if(nr>(red[n]&255)){red[n]=(byte)nr;changed=true;}if(ng>(green[n]&255)){green[n]=(byte)ng;changed=true;}if(nb>(blue[n]&255)){blue[n]=(byte)nb;changed=true;}
if(changed)enqueue(n);
}
}
double energy(int i){return ENERGY[Math.max(red[i]&255,Math.max(green[i]&255,blue[i]&255))];}
int directionAt(int i,int x,int y,int z){
double energy=energy(i);if(energy<=0)return 0;
double vx=0,vy=0,vz=0,total=0;
for(var d:DIRECTIONS){
int nx=x+d.getStepX(),ny=y+d.getStepY(),nz=z+d.getStepZ();
if(nx<0||nx>=side||ny<0||ny>=side||nz<0||nz>=side)continue;
int n=nx+nz*side+ny*side*side;double w=Math.max(0,energy(n)-energy);
if(w<=0||net.minecraft.world.level.lighting.LightEngine.getLightDampeningInto(states[n],states[i],d.getOpposite(),Math.max(1,states[i].getLightDampening()))>=15)continue;
vx+=d.getStepX()*w;vy+=d.getStepY()*w;vz+=d.getStepZ()*w;total+=w;
}
// The vector length is confidence: opposing arrivals cancel instead of flipping.
double scale=total>1e-12?127/total:0;
int a=(int)Math.round(255*energy/(energy+.08));
return a<<24|(int)Math.round(128+vx*scale)<<16|(int)Math.round(128+vy*scale)<<8|(int)Math.round(128+vz*scale);
}
NativeImage takeDirectionImage(){var result=directionImage;directionImage=null;return result;}
void enqueue(int i){if(!queued[i]){queued[i]=true;queue.enqueue(i);}}
NativeImage takeImage(){var result=image;image=null;return result;}
public void close(){if(image!=null){image.close();image=null;}}
public void close(){if(image!=null){image.close();image=null;}if(directionImage!=null){directionImage.close();directionImage=null;}}
}
// Logarithmic channels keep the source hue as light loses one native level per
// block. Independent maxima mix overlapping colours without summing unbounded light.
static int encode(int emission,int channel){return channel==0?0:Math.clamp((int)Math.round((emission+Math.log(channel/255.0)/Math.log(1.25))*16),0,240);}
// An offset preserves dim colour channels below native level zero. The shader
// applies the native cutoff to overall energy, never to each channel separately.
static int encode(int emission,int channel){return channel==0?0:Math.clamp((int)Math.round((emission+16+Math.log(channel/255.0)/Math.log(1.25))*8),0,248);}
}
@@ -25,54 +25,93 @@ public final class ColoredLights {
.withBindGroupLayout(BindGroupLayout.builder().withUniform("ColoredLightScene",UniformType.UNIFORM_BUFFER)
.withUniform("WorldDepth",UniformType.COMBINED_IMAGE_SAMPLER)
.withUniform("SceneColor",UniformType.COMBINED_IMAGE_SAMPLER)
.withUniform("LightVolume",UniformType.COMBINED_IMAGE_SAMPLER).build())
.withUniform("LightVolume",UniformType.COMBINED_IMAGE_SAMPLER)
.withUniform("PreviousVolume",UniformType.COMBINED_IMAGE_SAMPLER).build())
.withPrimitiveTopology(PrimitiveTopology.TRIANGLES).withCull(false)
.withColorTargetState(new ColorTargetState(Optional.of(new BlendFunction(BlendFactor.ONE,BlendFactor.ZERO,BlendFactor.ZERO,BlendFactor.ONE)),GpuFormat.RGBA8_UNORM,ColorTargetState.WRITE_COLOR))
.withDepthStencilState(Optional.empty()).build());
private static final ColoredLightVolume VOLUME=new ColoredLightVolume();
private static final Matrix4f projection=new Matrix4f();
private static TextureTarget source;
private static net.minecraft.client.renderer.texture.DynamicTexture black;
private static final ColoredDynamicLights DYNAMIC=new ColoredDynamicLights();
private static long activated,lastRender;
private static float displayedDistance;
private static int dynamicCount;
private static MappableRingBuffer uniforms;
private static boolean applied;
private static boolean applied,prepared;
private static int frames;
private ColoredLights(){}
public static void captureProjection(Matrix4f matrix){projection.set(matrix);}
public static void dirtyBlock(BlockPos pos){VOLUME.dirty(pos);}
public static void dirtyChunk(int x,int z){VOLUME.dirtyChunk(x,z);}
public static void dirtySection(int x,int y,int z){VOLUME.dirtySection(x,y,z);}
public static void render(){
applied=false;var mc=Minecraft.getInstance();
public static void dirtySection(int x,int y,int z){if(!fr.koka.sanctuary.client.DynamicLightClient.invalidatingSections())VOLUME.dirtySection(x,y,z);}
public static void prepare(){
applied=false;prepared=false;var mc=Minecraft.getInstance();
if(!VanillaLight.active()||!VanillaLight.coloredLights()||VanillaLight.coloredLightIntensity()==0||mc.level==null){release();return;}
if(PanoramaCapture.rendering())return;
var camera=mc.gameRenderer.gameRenderState().levelRenderState.cameraRenderState;
if(camera.fogType!=FogType.NONE)return;
VOLUME.update();if(VOLUME.texture()==null)return;
long now=System.nanoTime();if(activated==0){activated=now;displayedDistance=VanillaLight.coloredLightDistance();}
double elapsed=lastRender==0?0:Math.min(.1,(now-lastRender)/1e9);lastRender=now;
displayedDistance+=(VanillaLight.coloredLightDistance()-displayedDistance)*(float)(1-Math.exp(-elapsed*10));
VOLUME.update();
if(black==null){var image=new com.mojang.blaze3d.platform.NativeImage(1,1,false);image.setPixel(0,0,0);black=new net.minecraft.client.renderer.texture.DynamicTexture(()->"Empty coloured light",image);}
var lights=DYNAMIC.update(camera.pos);dynamicCount=lights.size();
var current=VOLUME.texture()==null?black:VOLUME.texture();
var previous=VOLUME.previous()==null?black:VOLUME.previous();
var main=mc.gameRenderer.mainRenderTarget();
if(source==null||source.width!=main.width||source.height!=main.height){if(source!=null)source.destroyBuffers();source=new TextureTarget("Sanctuary coloured light source",main.width,main.height,GpuFormat.RGBA8_UNORM,null);}
if(uniforms==null)uniforms=new MappableRingBuffer(()->"Sanctuary coloured light scene",GpuBuffer.USAGE_UNIFORM|GpuBuffer.USAGE_MAP_WRITE,128);
var base=VOLUME.origin();var fog=camera.fogData;
if(uniforms==null)uniforms=new MappableRingBuffer(()->"Sanctuary coloured light scene",GpuBuffer.USAGE_UNIFORM|GpuBuffer.USAGE_MAP_WRITE,176+ColoredDynamicLights.LIMIT*32);
var base=VOLUME.origin()==null?BlockPos.containing(camera.pos):VOLUME.origin();var fog=camera.fogData;
var old=VOLUME.previousOrigin()==null?base:VOLUME.previousOrigin();
int side=VOLUME.texture()==null?1:VOLUME.side(),oldSide=VOLUME.previous()==null?1:VOLUME.previousSide();
uniforms.rotate();try(var mapped=uniforms.currentBuffer().map(false,true)){
Std140Builder.intoBuffer(mapped.data()).putMat4f(new Matrix4f(projection).mul(camera.viewRotationMatrix).invert())
var data=Std140Builder.intoBuffer(mapped.data()).putMat4f(new Matrix4f(projection).mul(camera.viewRotationMatrix).invert())
.putVec4((float)(camera.pos.x-base.getX()),(float)(camera.pos.y-base.getY()),(float)(camera.pos.z-base.getZ()),VanillaLight.coloredLightIntensity()/100f)
.putVec4(RenderSystem.getDevice().getDeviceInfo().isZZeroToOne()?1:0,Math.clamp((15-mc.level.getSkyDarken())/15f,0,1),fog.color.w,0)
.putVec4(fog.renderDistanceStart,fog.renderDistanceEnd,fog.environmentalStart,fog.environmentalEnd)
.putVec4(ColoredLightVolume.SIDE,14,4,0);
.putVec4(displayedDistance,VOLUME.texture()==null?0:VOLUME.blend(),Math.clamp((now-activated)/300_000_000f,0,1),lights.size())
.putVec4(side,ColoredLightVolume.columns(side),current.getPixels().getWidth(),current.getPixels().getHeight())
.putVec4((float)(camera.pos.x-old.getX()),(float)(camera.pos.y-old.getY()),(float)(camera.pos.z-old.getZ()),VOLUME.previous()==null?0:1)
.putVec4(oldSide,ColoredLightVolume.columns(oldSide),previous.getPixels().getWidth(),previous.getPixels().getHeight());
for(int i=0;i<ColoredDynamicLights.LIMIT;i++){if(i<lights.size()){var l=lights.get(i);data.putVec4((float)(l.x-camera.pos.x),(float)(l.y-camera.pos.y),(float)(l.z-camera.pos.z),l.level);}else data.putVec4(0,0,0,0);}
for(int i=0;i<ColoredDynamicLights.LIMIT;i++){if(i<lights.size()){var l=lights.get(i);data.putVec4((float)l.r,(float)l.g,(float)l.b,(float)l.visibility);}else data.putVec4(0,0,0,0);}
}
prepared=true;
}
static void bindDirections(com.mojang.renderpearl.api.commands.RenderPass pass){
var nearest=RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST);
pass.setUniform("ColoredLightScene",uniforms.currentBuffer());
pass.setUniform("LightDirection",(VOLUME.direction()==null?black:VOLUME.direction()).getTextureView(),nearest);
pass.setUniform("PreviousDirection",(VOLUME.previousDirection()==null?black:VOLUME.previousDirection()).getTextureView(),nearest);
}
static boolean directionalReady(){return prepared&&VanillaLight.pbr();}
public static void render(){
if(!prepared)return;
var main=Minecraft.getInstance().gameRenderer.mainRenderTarget();
var current=VOLUME.texture()==null?black:VOLUME.texture();
var previous=VOLUME.previous()==null?black:VOLUME.previous();
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);
try(var pass=encoder.createRenderPass(()->"Sanctuary coloured light",main.getColorTextureView(),Optional.empty())){
pass.setPipeline(RenderSystem.getCompiledPipeline(APPLY));pass.setUniform("ColoredLightScene",uniforms.currentBuffer());
pass.setUniform("WorldDepth",main.getDepthTextureView(),nearest);pass.setUniform("SceneColor",source.getColorTextureView(),nearest);pass.setUniform("LightVolume",VOLUME.texture().getTextureView(),nearest);pass.draw(3,1,0,0);
pass.setUniform("WorldDepth",main.getDepthTextureView(),nearest);pass.setUniform("SceneColor",source.getColorTextureView(),nearest);pass.setUniform("LightVolume",current.getTextureView(),nearest);pass.setUniform("PreviousVolume",previous.getTextureView(),nearest);pass.draw(3,1,0,0);
}
applied=true;frames++;
}
public static void release(){VOLUME.close();if(source!=null){source.destroyBuffers();source=null;}if(uniforms!=null){uniforms.close();uniforms=null;}applied=false;}
public static void release(){prepared=false;VOLUME.close();DYNAMIC.clear();activated=lastRender=0;dynamicCount=0;if(black!=null){black.close();black=null;}if(source!=null){source.destroyBuffers();source=null;}if(uniforms!=null){uniforms.close();uniforms=null;}applied=false;}
static boolean applied(){return applied;}
static boolean allocated(){return source!=null||VOLUME.texture()!=null;}
static boolean settled(){return VOLUME.settled();}
static int renderedFrames(){return frames;}
static int builds(){return VOLUME.completed();}
static int sample(BlockPos pos){return VOLUME.sample(pos);}
static net.minecraft.world.phys.Vec3 dynamicPosition(long entity){return DYNAMIC.position(entity);}
static int dynamicCount(){return dynamicCount;}
static float blend(){return VOLUME.blend();}
static int volumeSide(){return VOLUME.side();}
static int directionSample(BlockPos pos){return VOLUME.directionSample(pos);}
static int materials(){return VOLUME.cachedStates();}
}
@@ -30,6 +30,7 @@ final class PbrTerrain implements AutoCloseable {
final PbrMaterials materials=new PbrMaterials();
private Build building;
private long bytes;
private int completed;
private Object models,level;
private List<Section> visible=List.of();
record Entry(BlockPos origin,GpuBuffer vertices,int indices){}
@@ -45,7 +46,11 @@ final class PbrTerrain implements AutoCloseable {
if(chunk==null)continue;
var storage=chunk.getSection(mc.level.getSectionIndex(pos.getY()));
var section=sections.get(key);
if(section==null||section.storage!=storage){if(section!=null)remove(section);section=new Section(key,pos.immutable(),storage);sections.put(key,section);}
if(section==null||section.storage!=storage){
Entry retained=section==null?null:section.entry;
if(section!=null){section.entry=null;remove(section);}
section=new Section(key,pos.immutable(),storage);section.entry=retained;sections.put(key,section);
}
section.seen=frame;active.add(section);
}
visible=active;
@@ -56,23 +61,30 @@ 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)){next.dirty=false;continue;}
if(!next.storage.maybeHas(materials::candidate)){releaseEntry(next);next.dirty=false;continue;}
building=new Build(next);
}
if(!advance(building,deadline))break;
var done=building;building=null;
try(done){
done.section.dirty=false;
done.section.dirty=done.section.revision!=done.revision;
Entry replacement=null;
if(done.quads>0&&!done.overflow){
try(var mesh=done.out.buildOrThrow()){
long size=mesh.vertexBuffer().remaining();
if(bytes+size<=MAX_BYTES){
long replaced=done.section.entry==null?0:done.section.entry.vertices.size();
if(bytes-replaced+size<=MAX_BYTES){
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);
done.section.entry=new Entry(done.section.origin,buffer,count);bytes+=buffer.size();
replacement=new Entry(done.section.origin,buffer,count);
}
}
}
// Publish atomically. A dirty section stays visible while its new
// geometry is built, including when light updates arrive mid-build.
releaseEntry(done.section);done.section.entry=replacement;
if(replacement!=null)bytes+=replacement.vertices.size();
completed++;
}
}
return active.stream().map(s->s.entry).filter(Objects::nonNull).toList();
@@ -118,18 +130,22 @@ final class PbrTerrain implements AutoCloseable {
void dirtySection(int x,int y,int z){dirty(sections.get(SectionPos.asLong(x,y,z)));}
void dirty(BlockPos pos){for(int x=(pos.getX()-1)>>4;x<=(pos.getX()+1)>>4;x++)for(int y=(pos.getY()-1)>>4;y<=(pos.getY()+1)>>4;y++)for(int z=(pos.getZ()-1)>>4;z<=(pos.getZ()+1)>>4;z++)dirty(sections.get(SectionPos.asLong(x,y,z)));}
void dirtyChunk(int x,int z){for(var section:sections.values())if(Math.abs((section.origin.getX()>>4)-x)<=1&&Math.abs((section.origin.getZ()>>4)-z)<=1)dirty(section);}
private void dirty(Section section){if(section!=null){remove(section);section.dirty=true;}}
private void remove(Section section){if(building!=null&&building.section==section){building.close();building=null;}if(section.entry!=null){bytes-=section.entry.vertices.size();section.entry.vertices.close();section.entry=null;}}
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);}
int entryCount(){return (int)visible.stream().filter(s->s.entry!=null).count();}
int completed(){return completed;}
boolean building(){return building!=null;}
boolean settled(){return building==null&&visible.stream().noneMatch(s->s.dirty);}
public void close(){if(building!=null){building.close();building=null;}for(var section:sections.values())remove(section);sections.clear();visible=List.of();materials.close();models=level=null;bytes=0;}
private static final class Section{
final long key;final BlockPos origin;final LevelChunkSection storage;long seen;boolean dirty=true;Entry entry;
final long key;final BlockPos origin;final LevelChunkSection storage;long seen,revision;boolean dirty=true;Entry entry;
Section(long key,BlockPos origin,LevelChunkSection storage){this.key=key;this.origin=origin;this.storage=storage;}
}
private static final class Build implements AutoCloseable{
final Section section;final ByteBufferBuilder buffer=new ByteBufferBuilder(65536);final BufferBuilder out=new BufferBuilder(buffer,PrimitiveTopology.QUADS,FORMAT);
final Section section;final long revision;final ByteBufferBuilder buffer=new ByteBufferBuilder(65536);final BufferBuilder out=new BufferBuilder(buffer,PrimitiveTopology.QUADS,FORMAT);
int cursor,quads;boolean overflow;
Build(Section section){this.section=section;}
Build(Section section){this.section=section;revision=section.revision;}
public void close(){buffer.close();}
}
}
@@ -28,6 +28,7 @@ public final class ProceduralPbr {
private static final BindGroupLayout SCENE=BindGroupLayout.builder().withUniform("PbrScene",UniformType.UNIFORM_BUFFER)
.withUniform("PixelShadows",UniformType.UNIFORM_BUFFER).withUniform("SunDepth",UniformType.COMBINED_IMAGE_SAMPLER)
.withUniform("WorldDepth",UniformType.COMBINED_IMAGE_SAMPLER).withUniform("SceneColor",UniformType.COMBINED_IMAGE_SAMPLER)
.withUniform("ColoredLightScene",UniformType.UNIFORM_BUFFER).withUniform("LightDirection",UniformType.COMBINED_IMAGE_SAMPLER).withUniform("PreviousDirection",UniformType.COMBINED_IMAGE_SAMPLER)
.withUniform("BlockAtlas",UniformType.COMBINED_IMAGE_SAMPLER).withUniform("MaterialAtlas",UniformType.COMBINED_IMAGE_SAMPLER).build();
private static final RenderPipeline PIPELINE=RenderPipelines.register(RenderPipeline.builder()
.withLocation(Identifier.parse("sanctuary:pipeline/pbr")).withVertexShader(Identifier.parse("sanctuary:core/pbr"))
@@ -44,7 +45,7 @@ public final class 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){TERRAIN.dirtySection(x,y,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 render(){
applied=false;var mc=Minecraft.getInstance();
if(!VanillaLight.active()||!VanillaLight.pbr()||VanillaLight.pbrIntensity()==0||mc.level==null){release();return;}
@@ -52,8 +53,8 @@ public final class ProceduralPbr {
if(PanoramaCapture.rendering()||camera.fogType!=FogType.NONE||sky.skybox!=DimensionType.Skybox.OVERWORLD)return;
var main=mc.gameRenderer.mainRenderTarget();
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);}
// The extra padding also supplies a valid inactive PixelShadows binding (192 bytes).
if(scene==null)scene=new MappableRingBuffer(()->"Sanctuary PBR scene",GpuBuffer.USAGE_UNIFORM|GpuBuffer.USAGE_MAP_WRITE,192);
// 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 vp=new Matrix4f(projection).mul(camera.viewRotationMatrix);var fog=camera.fogData;
var sun=PixelShadows.sunDirection(sky.sunAngle);boolean lunar=sun.y<=.08f;
@@ -65,7 +66,7 @@ public final class ProceduralPbr {
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(fog.renderDistanceStart,fog.renderDistanceEnd,fog.environmentalStart,fog.environmentalEnd).putVec4(fog.color.w,0,0,0)
.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);
}
var transforms=new ArrayList<GpuBufferSlice>(entries.size());int max=0;
@@ -81,7 +82,10 @@ public final class ProceduralPbr {
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.setIndexBuffer(indices.getBuffer(),indices.type());
pass.setUniform("MaterialAtlas",atlas.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);}
}
applied=true;frames++;
@@ -89,5 +93,8 @@ public final class ProceduralPbr {
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 boolean allocated(){return source!=null;}static int renderedFrames(){return frames;}
static int terrainEntries(){return TERRAIN.entryCount();}
static int terrainBuilds(){return TERRAIN.completed();}
static boolean terrainBuilding(){return TERRAIN.building();}
static int materialCount(){return TERRAIN.materials.count();}static int analyses(){return TERRAIN.materials.analyses();}
}
@@ -25,7 +25,12 @@ public final class ShaderOptionsScreen extends OptionsSubScreen {
slider("pbr_intensity",VanillaLight.pbrIntensity(),VanillaLight::setPbrIntensity);
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(
Component.translatable("sanctuary.shaders.warm_torches_help")),VanillaLight.warmTorches(),VanillaLight::setWarmTorches));
slider("colored_light_intensity",VanillaLight.coloredLightIntensity(),VanillaLight::setColoredLightIntensity);
list.addBig(new OptionInstance<Integer>("sanctuary.shaders.colored_light_distance",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.colored_light_distance_help")),(caption,value)->Component.translatable("sanctuary.shaders.colored_light_distance_value",value),
new OptionInstance.Enum<>(List.of(8,16,32,64),Codec.INT),VanillaLight.coloredLightDistance(),VanillaLight::setColoredLightDistance));
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.ssgi",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.ssgi_help")),VanillaLight.ssgi(),VanillaLight::setSsgi));
slider("ssgi_intensity",VanillaLight.ssgiIntensity(),VanillaLight::setSsgiIntensity);
@@ -24,10 +24,12 @@ public final class VanillaLight {
boolean lensFlare=true;
int lensFlareIntensity=30;
boolean pbr=true;
int pbrIntensity=50;
int pbrIntensity=80;
boolean ssgi=true;
boolean coloredLights=false;
int coloredLightIntensity=35;
boolean coloredLights=true;
boolean warmTorches=false;
int coloredLightIntensity=30;
int coloredLightDistance=16;
int ssgiIntensity=35;
int bloomIntensity=20;
int bloomRadius=50;
@@ -49,6 +51,7 @@ public final class VanillaLight {
preferences.lensFlareIntensity=Math.clamp(preferences.lensFlareIntensity,0,100);
preferences.pbrIntensity=Math.clamp(preferences.pbrIntensity,0,100);
preferences.coloredLightIntensity=Math.clamp(preferences.coloredLightIntensity,0,100);
preferences.coloredLightDistance=normalizeColoredLightDistance(preferences.coloredLightDistance);
preferences.ssgiIntensity=Math.clamp(preferences.ssgiIntensity,0,100);
preferences.bloomIntensity=Math.clamp(preferences.bloomIntensity,0,100);
preferences.bloomRadius=Math.clamp(preferences.bloomRadius,0,100);
@@ -82,10 +85,15 @@ public final class VanillaLight {
public static int pbrIntensity(){enabled();return preferences.pbrIntensity;}
public static void setPbr(boolean value){enabled();preferences.pbr=value;save();}
public static void setPbrIntensity(int value){enabled();preferences.pbrIntensity=Math.clamp(value,0,100);save();}
public static boolean warmTorches(){enabled();return preferences.warmTorches;}
public static void setWarmTorches(boolean value){enabled();preferences.warmTorches=value;save();}
public static boolean coloredLights(){enabled();return preferences.coloredLights;}
public static int coloredLightIntensity(){enabled();return preferences.coloredLightIntensity;}
public static void setColoredLights(boolean value){enabled();preferences.coloredLights=value;save();}
public static void setColoredLightIntensity(int value){enabled();preferences.coloredLightIntensity=Math.clamp(value,0,100);save();}
public static int coloredLightDistance(){enabled();return preferences.coloredLightDistance;}
public static void setColoredLightDistance(int value){enabled();preferences.coloredLightDistance=normalizeColoredLightDistance(value);save();}
static int normalizeColoredLightDistance(int value){return value<12?8:value<24?16:value<48?32:64;}
public static boolean ssgi(){enabled();return preferences.ssgi;}
public static int ssgiIntensity(){enabled();return preferences.ssgiIntensity;}
public static void setSsgi(boolean value){enabled();preferences.ssgi=value;save();}
@@ -7,7 +7,8 @@ import java.util.function.LongConsumer;
/** Immutable render snapshots; entity collection and the dirty queue belong to the client tick. */
public final class DynamicLightField {
public record Id(boolean drop, long value) {}
public record Source(Id id, double x, double y, double z, int light) {
public record Source(Id id, double x, double y, double z, int light, int color) {
public Source(Id id,double x,double y,double z,int light){this(id,x,y,z,light,0xFFFFFF);}
public Source {
if (!Double.isFinite(x+y+z) || light<1 || light>15) throw new IllegalArgumentException("light source");
}
@@ -20,9 +21,12 @@ public final class DynamicLightField {
private final int cellSize;
public Collector(int cellSize) {this.cellSize=Math.max(1,cellSize);}
public void add(long entity,boolean drop,double x,double y,double z,int light) {
add(entity,drop,x,y,z,light,0xFFFFFF);
}
public void add(long entity,boolean drop,double x,double y,double z,int light,int color) {
if(light<=0)return;
Id id=new Id(drop,drop ? sectionKey(floor(x/cellSize),floor(y/cellSize),floor(z/cellSize)) : entity);
Source candidate=new Source(id,snap(x),snap(y),snap(z),Math.min(15,light));
Source candidate=new Source(id,snap(x),snap(y),snap(z),Math.min(15,light),color);
sources.merge(id,candidate,(a,b)->compareRepresentative(a,b)<=0?a:b);
}
public List<Source> finish(int limit,double x,double y,double z,long priorityEntity) {
@@ -53,6 +57,7 @@ public final class DynamicLightField {
lists.long2ObjectEntrySet().forEach(e->bins.put(e.getLongKey(),e.getValue().toArray(Source[]::new)));
}
public int size() {return sources.size();}
public Collection<Source> sources(){return sources.values();}
public int sectionCount() {return bins.size();}
public int sampleCount(double x,double y,double z) {
Source[] near=bins.get(sectionKey(floor(x)>>4,floor(y)>>4,floor(z)>>4));return near==null?0:near.length;
@@ -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) { 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(); 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,24 @@
package fr.koka.sanctuary.mixin.client;
import com.mojang.renderpearl.api.device.GpuBackend;
import net.minecraft.client.PreferredGraphicsApi;
import net.minecraft.network.chat.Component;
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;
/** Sanctuary's default uses native Vulkan with Minecraft's OpenGL fallback. */
@Mixin(PreferredGraphicsApi.class)
abstract class PreferredGraphicsApiMixin {
@Inject(method="getBackendsToTry", at=@At("HEAD"), cancellable=true)
private void sanctuary$vulkanDefault(CallbackInfoReturnable<GpuBackend[]> ci) {
if ((Object)this == PreferredGraphicsApi.DEFAULT)
ci.setReturnValue(PreferredGraphicsApi.VULKAN.getBackendsToTry());
}
@Inject(method="caption", at=@At("HEAD"), cancellable=true)
private void sanctuary$defaultCaption(CallbackInfoReturnable<Component> ci) {
if ((Object)this == PreferredGraphicsApi.DEFAULT)
ci.setReturnValue(Component.translatable("sanctuary.graphics.default_vulkan"));
}
}
@@ -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,5 @@
{
"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.",
"sanctuary.weather.overcast": "Overcast",
@@ -2295,7 +2296,7 @@
"sanctuary.shaders.ssgi_intensity_help": "Strength of diffuse color bounce. Zero stops rendering and frees its resources. Does not change world light.",
"sanctuary.shaders.ssgi_intensity_value": "SSGI: %s%%",
"sanctuary.shaders.pbr": "PBR · material relief",
"sanctuary.shaders.pbr_help": "Experimental: procedural block normals and roughness, sun and moon highlights. Range 32 blocks. ON by default.",
"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%%",
@@ -2310,8 +2311,13 @@
"sanctuary.shaders.sunshaft_intensity_help": "Strength of light scattered in the air. Beams automatically thicken with fog and precipitation. Zero disables the effect.",
"sanctuary.shaders.sunshaft_intensity_value": "Sunshafts: %s %%",
"sanctuary.shaders.colored_lights": "Colored lights",
"sanctuary.shaders.colored_lights_help": "Light-emitting blocks tint their surroundings, even off screen. Local propagation respects obstacles and does not change gameplay light levels.",
"sanctuary.shaders.colored_lights_help": "Light-emitting blocks and carried items tint their surroundings, even off screen. Smooth appearance without changing gameplay light levels.",
"sanctuary.shaders.colored_light_intensity": "Colored light intensity",
"sanctuary.shaders.colored_light_intensity_value": "Colored lights: %s %%",
"sanctuary.shaders.colored_light_intensity_help": "Subtle tint at 35%. Zero disables the calculation. Source changes update progressively."
"sanctuary.shaders.colored_light_intensity_help": "Enabled at 30% by default. Zero disables computation. Changes fade in smoothly.",
"sanctuary.shaders.colored_light_distance": "Colored light distance",
"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."
}
@@ -1,4 +1,5 @@
{
"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.",
"sanctuary.weather.overcast": "Ciel gris",
@@ -2295,7 +2296,7 @@
"sanctuary.shaders.ssgi_intensity_help": "Force de la teinte diffusée. Zéro coupe le calcul et libère ses ressources. Aucun changement de lumière du monde.",
"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 et de la lune. Portée 32 blocs. Activé par défaut.",
"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 %%",
@@ -2310,8 +2311,13 @@
"sanctuary.shaders.sunshaft_intensity_help": "Force de la lumière dans lair. Les rayons s’épaississent automatiquement avec le brouillard et les précipitations. Zéro désactive le calcul.",
"sanctuary.shaders.sunshaft_intensity_value": "Rayons : %s %%",
"sanctuary.shaders.colored_lights": "Lumières colorées",
"sanctuary.shaders.colored_lights_help": "Les blocs lumineux teintent leurs environs, même hors écran. Propagation locale avec obstacles, sans changer les règles de lumière du jeu.",
"sanctuary.shaders.colored_lights_help": "Les blocs lumineux et les objets portés teintent leurs environs, même hors écran. Apparition progressive, sans changer les règles de lumière du jeu.",
"sanctuary.shaders.colored_light_intensity": "Intensité des lumières colorées",
"sanctuary.shaders.colored_light_intensity_value": "Lumières colorées : %s %%",
"sanctuary.shaders.colored_light_intensity_help": "Teinte douce à 35 %. Zéro désactive le calcul. Les changements de sources sont actualisés progressivement."
"sanctuary.shaders.colored_light_intensity_help": "Activé par défaut à 30 %. Zéro désactive le calcul. Les changements apparaissent progressivement.",
"sanctuary.shaders.colored_light_distance": "Distance des lumières colorées",
"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."
}
@@ -3,12 +3,18 @@
uniform sampler2D WorldDepth;
uniform sampler2D SceneColor;
uniform sampler2D LightVolume;
uniform sampler2D PreviousVolume;
layout(std140) uniform ColoredLightScene {
mat4 InverseViewProjection;
vec4 CameraStrength;
vec4 ClipSkyFog;
vec4 FogDistance;
vec4 VolumeSize;
vec4 Atlas;
vec4 PreviousCamera;
vec4 PreviousAtlas;
vec4 DynamicPosition[128];
vec4 DynamicColor[128];
};
layout(location=0) in vec2 texCoord;
layout(location=0) out vec4 fragColor;
@@ -27,20 +33,32 @@ vec3 normalAt(vec3 p){
vec3 n=cross(dx*inversesqrt(xx),dy*inversesqrt(yy));float nn=dot(n,n);if(nn<1e-12)return vec3(0);
n*=inversesqrt(nn);return dot(n,-p)<0.0?-n:n;
}
vec4 cell(ivec3 p){
p=clamp(p,ivec3(0),ivec3(63));
vec4 v=texelFetch(LightVolume,ivec2(p.x+(p.y%8)*64,p.z+(p.y/8)*64),0);
// Decode Q4.4 logarithmic RGB. Black stays exactly black.
v.rgb=max(vec3(0),(pow(vec3(.8),vec3(15)-v.rgb*(255.0/16.0))-vec3(pow(.8,15.0)))/(1.0-pow(.8,15.0)));
vec4 cell(sampler2D volume,ivec3 p,vec4 atlas){
int side=int(atlas.x),cols=int(atlas.y);
p=clamp(p,ivec3(0),ivec3(side-1));
vec4 v=texelFetch(volume,ivec2(p.x+(p.y%cols)*side,p.z+(p.y/cols)*side),0);
vec3 spectrum=pow(vec3(.8),vec3(31)-v.rgb*(255.0/8.0))*step(vec3(.5/255.0),v.rgb);
float peak=max(spectrum.r,max(spectrum.g,spectrum.b));
float energy=max(0.0,(peak-pow(.8,15.0))/(1.0-pow(.8,15.0)));
v.rgb=spectrum*(energy/max(peak,1e-12));
return v;
}
vec4 field(vec3 p){
vec4 field(sampler2D volume,vec3 p,vec4 atlas){
vec3 edges=min(p,vec3(atlas.x)-p);
float margin=smoothstep(14.0,18.0,min(edges.x,min(edges.y,edges.z)));if(margin<=0.0)return vec4(0);
vec3 at=p-.5;ivec3 base=ivec3(floor(at));vec3 f=fract(at);vec4 result=vec4(0);
for(int y=0;y<2;y++)for(int z=0;z<2;z++)for(int x=0;x<2;x++){
float w=(x==0?1.0-f.x:f.x)*(y==0?1.0-f.y:f.y)*(z==0?1.0-f.z:f.z);
result+=cell(base+ivec3(x,y,z))*w;
result+=cell(volume,base+ivec3(x,y,z),atlas)*w;
}
return result;
result.rgb*=margin;return result;
}
vec3 tintGain(vec4 light,float amount){
float energy=max(light.r,max(light.g,light.b));if(energy<1e-12)return vec3(1);
vec3 tint=clamp(light.rgb/max(dot(light.rgb,vec3(.2126,.7152,.0722)),1e-12),vec3(.02),vec3(3));
// Linear close to darkness: no fourth-root halo at the edge of a light pool.
float response=energy/(energy+.08);
return pow(tint,vec3(clamp(amount*response*(1.0-.8*light.a*ClipSkyFog.y),0.0,.95)));
}
void main(){
vec3 scene=texture(SceneColor,texCoord).rgb;fragColor=vec4(scene,0);
@@ -49,13 +67,25 @@ void main(){
vec3 a=abs(n);float axis=max(a.x,max(a.y,a.z));
// Sample outside opaque block faces. Bilinear atlas filtering would leak
// across unrelated slices, so the eight neighbours are explicitly addressed.
vec3 local=p+CameraStrength.xyz+n*(axis>.995?.51:.06);
vec3 edge=min(local,vec3(VolumeSize.x)-local);
float border=smoothstep(VolumeSize.y,VolumeSize.y+VolumeSize.z,min(edge.x,min(edge.y,edge.z)));if(border<=0.0)return;
vec4 light=field(local);float energy=max(light.r,max(light.g,light.b));if(energy<.0001)return;
float luminance=dot(light.rgb,vec3(.2126,.7152,.0722));
vec3 tint=clamp(light.rgb/max(luminance,.0001),vec3(.15),vec3(2));
vec3 offset=n*(axis>.995?.51:.06);
float border=1.0-smoothstep(VolumeSize.x*.75,VolumeSize.x,length(p));if(border<=0.0)return;
vec4 light=field(LightVolume,p+CameraStrength.xyz+offset,Atlas);
vec4 before=VolumeSize.y<1.0&&PreviousCamera.w>.5?field(PreviousVolume,p+PreviousCamera.xyz+offset,PreviousAtlas):vec4(0);
vec3 dynamic=vec3(0);
for(int i=0;i<int(VolumeSize.w);i++){
vec3 delta=p-DynamicPosition[i].xyz;float square=dot(delta,delta),radius=DynamicPosition[i].w;
if(square>=radius*radius)continue;
float remaining=radius-sqrt(square);
float power=max(0.0,(pow(.8,15.0-remaining)-pow(.8,15.0))/(1.0-pow(.8,15.0)));
float response=power/(power+.08)*smoothstep(0.0,1.0,DynamicColor[i].a);
float fadedPower=.08*response/max(1e-6,1.0-response);
dynamic=max(dynamic,DynamicColor[i].rgb*fadedPower);
}
light.rgb=max(light.rgb,dynamic);before.rgb=max(before.rgb,dynamic);
float fog=max(clamp((max(length(p.xz),abs(p.y))-FogDistance.x)/max(.001,FogDistance.y-FogDistance.x),0,1),clamp((length(p)-FogDistance.z)/max(.001,FogDistance.w-FogDistance.z),0,1));
float strength=CameraStrength.w*.8*sqrt(energy)*border*(1.0-.8*light.a*ClipSkyFog.y)*(1.0-fog*ClipSkyFog.z);
fragColor=vec4(scene*mix(vec3(1),tint,strength),0);
float amount=CameraStrength.w*1.35*border*(1.0-fog*ClipSkyFog.z)*smoothstep(0.0,1.0,VolumeSize.z);
vec3 gain=tintGain(light,amount);
// Fade the rendered response, preserving colour and avoiding a bright first frame.
if(VolumeSize.y<1.0)gain=mix(tintGain(before,amount),gain,smoothstep(0.0,1.0,VolumeSize.y));
fragColor=vec4(scene*gain,0);
}
@@ -5,6 +5,20 @@ uniform sampler2D SceneColor;
uniform sampler2D BlockAtlas;
uniform sampler2D MaterialAtlas;
uniform sampler2D SunDepth;
uniform sampler2D LightDirection;
uniform sampler2D PreviousDirection;
layout(std140) uniform ColoredLightScene {
mat4 LocalInverseViewProjection;
vec4 CameraStrength;
vec4 ClipSkyFog;
vec4 FogDistance;
vec4 VolumeSize;
vec4 Atlas;
vec4 PreviousCamera;
vec4 PreviousAtlas;
vec4 DynamicPosition[128];
vec4 DynamicColor[128];
};
layout(std140) uniform PbrScene {
mat4 PbrInverseVP;
vec4 PbrSun;
@@ -34,7 +48,55 @@ float visibility(vec3 p,vec3 n){
vec3 q=light.xyz/light.w;q.xy=q.xy*0.5+0.5;
if(DistanceClipFog.y<0.5)q.z=q.z*0.5+0.5;
if(any(lessThan(q,vec3(0)))||any(greaterThan(q,vec3(1))))return 1.0;
return q.z>texture(SunDepth,q.xy).r+0.00015?0.0:1.0;
return q.z>textureLod(SunDepth,q.xy,0.0).r+0.00015?0.0:1.0;
}
// Cached direction moments; filtering moments before normalization avoids unstable
// switches between nearby/opposed sources. Alpha stores the light response.
vec4 directionField(sampler2D field,vec3 p,vec4 atlas){
if(textureSize(field,0).x<int(atlas.z))return vec4(0);
vec3 edges=min(p,vec3(atlas.x)-p);float margin=smoothstep(14.0,18.0,min(edges.x,min(edges.y,edges.z)));
if(margin<=0.0)return vec4(0);
int side=int(atlas.x),cols=int(atlas.y);vec3 at=p-.5;ivec3 base=ivec3(floor(at));vec3 f=fract(at);vec4 sum=vec4(0);
for(int y=0;y<2;y++)for(int z=0;z<2;z++)for(int x=0;x<2;x++){
ivec3 q=clamp(base+ivec3(x,y,z),ivec3(0),ivec3(side-1));
vec4 cell=texelFetch(field,ivec2(q.x+(q.y%cols)*side,q.z+(q.y/cols)*side),0);
float w=(x==0?1.0-f.x:f.x)*(y==0?1.0-f.y:f.y)*(z==0?1.0-f.z:f.z);
sum+=vec4((cell.rgb*255.0-128.0)/127.0*cell.a,cell.a)*w;
}
return sum*margin;
}
vec4 localArrival(vec3 p,vec3 normal){
if(PbrEnvironment.y<.5)return vec4(0);
vec3 offset=normal*.51;
vec4 field=directionField(LightDirection,p+CameraStrength.xyz+offset,Atlas);
if(VolumeSize.y<1.0){
vec4 before=PreviousCamera.w>.5?directionField(PreviousDirection,p+PreviousCamera.xyz+offset,PreviousAtlas):vec4(0);
field=mix(before,field,smoothstep(0.0,1.0,VolumeSize.y));
}
for(int i=0;i<int(VolumeSize.w);i++){
vec3 delta=DynamicPosition[i].xyz-p;float distance=length(delta),radius=DynamicPosition[i].w;
if(distance>=radius||distance<.001)continue;
float power=max(0.0,(pow(.8,15.0-radius+distance)-pow(.8,15.0))/(1.0-pow(.8,15.0)));
float weight=power/(power+.08)*smoothstep(0.0,1.0,DynamicColor[i].a);
field+=vec4(delta/distance*weight,weight);
}
float magnitude=length(field.xyz);if(magnitude<.00001||field.a<.00001)return vec4(0);
float confidence=smoothstep(.02,1.0,magnitude/field.a);
float fade=(1.0-smoothstep(VolumeSize.x*.75,VolumeSize.x,length(p)))*smoothstep(0.0,1.0,VolumeSize.z);
return vec4(field.xyz/magnitude,confidence*min(field.a,1.0)*fade*clamp(CameraStrength.w/0.3,0.0,1.0));
}
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 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);
return distribution*geometry*fresnel/max(4.0*nv*nl,.0001)*nl;
}
void main(){
// Derivatives precede divergent discard; UV-space tangents preserve rotated/mirrored block textures.
@@ -42,37 +104,40 @@ 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=texture(WorldDepth,uv).r;
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;
vec4 texel=texture(BlockAtlas,blockUv);if(texel.a<0.99)discard;
// Keep atlas derivatives valid after per-fragment depth/alpha rejection.
vec4 texel=textureGrad(BlockAtlas,blockUv,tx,ty);if(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=texture(MaterialAtlas,maskUv);
vec4 material=textureLod(MaterialAtlas,maskUv,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;
vec3 n=normalize(baseNormal+tangent*slope.x+bitangent*slope.y);
vec3 l=normalize(PbrSun.xyz),v=normalize(-relativePosition),h=normalize(l+v);
float nl=max(dot(n,l),0.0),nv=max(dot(n,v),0.001),nh=max(dot(n,h),0.0),vh=max(dot(v,h),0.0);
float roughness=max(material.b,0.25),alpha=roughness*roughness,a2=alpha*alpha;
float denom=nh*nh*(a2-1.0)+1.0;
float distribution=a2/max(3.14159265*denom*denom,0.00001);
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,0.00001);
// 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 f0=mix(vec3(0.04),albedo,material.a);
vec3 fresnel=f0+(1.0-f0)*pow(1.0-vh,5.0);
vec3 specular=distribution*geometry*fresnel/max(4.0*nv*nl,0.0001)*nl;
vec3 specular=localShine(n,v,l,material,albedo);
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)
*(1.0-smoothstep(PbrParams.w*.75,PbrParams.w,length(relativePosition)));
if(amount<=0.0001)discard;
vec3 scene=texture(SceneColor,uv).rgb;vec3 linear=scene*scene;
vec4 arrival=localArrival(relativePosition,baseNormal);
float localAmount=arrival.w*(1.0-fog*PbrEnvironment.x)*(1.0-smoothstep(PbrParams.w*.75,PbrParams.w,length(relativePosition)));
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;
vec3 lit=clamp(linear*(1.0+relief*amount),0.0,1.0);
vec3 shine=min(specular*PbrParams.x*amount*3.0,vec3(0.5));
if(localAmount>.0001){
float localRelief=(max(dot(n,arrival.xyz),0.0)-max(dot(baseNormal,arrival.xyz),0.0))*.7;
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));
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);
}
@@ -277,7 +277,8 @@
"client.SculptureParticlesMixin",
"client.PixelShadowRendererMixin",
"client.PixelShadowLevelMixin",
"client.PixelShadowDirtyMixin"
"client.PixelShadowDirtyMixin",
"client.PreferredGraphicsApiMixin"
],
"injectors": {
"defaultRequire": 1
@@ -52,6 +52,13 @@ public final class Dynamic030Smoke {
for(var light:lights)expected=Math.max(expected,(int)Math.max(0,(light.light()-Math.sqrt(light.distanceSquared(x,y,z)))*16));
require(indexed.apply(nativeSky,x,y,z)==(nativeSky|expected),"Indexed samples agree with brute-force oracle");
}
var colored=new DynamicLightField.Collector(4);colored.add(42,false,2,3,4,10,0x55BDFF);
var blue=colored.finish(32,0,0,0,42);var blueField=new DynamicLightField.Snapshot(blue);
require(blueField.sources().iterator().next().color()==0x55BDFF,"Snapshot retains source colour");
require((blueField.apply(nativeSky,2,3,4)&255)==160,"RGB metadata preserves native intensity");
var changed=new DynamicLightField.Collector(4);changed.add(42,false,2,3,4,10,0x62FFA0);
field.update(blue);require(field.update(changed.finish(32,0,0,0,42)),"Same intensity can change colour");
require(blueField.sources().iterator().next().color()==0x55BDFF,"Older coloured snapshot remains immutable");
long begin=System.nanoTime();long sum=0;
for(int i=0;i<1000000;i++)sum+=stress.apply(0,i%80,2,(i/80)%80);
System.out.printf("LIGHT-01 passed: 10,000-drop clustering, 100,000 oracle samples; 1M cached queries %.1f ms (checksum %d)%n",(System.nanoTime()-begin)/1e6,sum);
+1 -1
View File
@@ -1,6 +1,6 @@
name = "Sanctuary"
author = "KOKA99CAB"
version = "beta.139"
version = "beta.143"
pack-format = "packwiz:1.1.0"
[index]