Compare commits

...
Author SHA1 Message Date
koka fc4a0cb68e feat: add optional PBR and filter SSGI in beta.134
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 22:54:38 +02:00
koka 17172fd18b docs: record beta.133 release and Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 22:19:32 +02:00
koka 216d3f75b5 feat: make SSGI color bounce visible in beta.133
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 22:18:12 +02:00
koka e48fc656c7 docs: record beta.132 release and Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 21:58:39 +02:00
koka 7e21781a9a feat(shader): experimental local diffuse SSGI in beta.132
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 21:57:04 +02:00
koka d941b91265 docs: record beta.131 release and Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 21:34:00 +02:00
koka 078d3e6833 feat(shader): independent emissive cores and precision options in beta.131
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 21:32:28 +02:00
koka afa77f3d62 docs: record beta.130 release and preserved Prism data
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 21:06:31 +02:00
koka 534dfec4f8 feat(shader): selective bloom and shared precision in beta.130
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 21:04:33 +02:00
koka 26e305d15f Record beta.129 publication and verified Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 19:13:09 +02:00
koka 35103a7ea8 Add connected edge highlights and stable shadow projection for beta.129
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 19:11:11 +02:00
51 changed files with 3037 additions and 84 deletions
+51 -8
View File
@@ -20,14 +20,11 @@ La [vision complète](docs/vision.md) conserve les intentions ; le
L’économie, les claims et les dimensions décrits dans la vision restent
à implémenter.
La version **beta.128** stabilise les ombres pixélisées, atténue le soleil au
zénith et ajoute les ombres légères de la lune et le feuillage irrégulier.
**Options → Shaders → Sanctuary · Vanilla Light** : les réglages de finesse et
de portée sont conservés. Le [ticket de livraison](docs/shader-beta128.md)
décrit le résultat et ses vérifications. Les gemmes, sculptures et découvertes
de beta.126 restent inchangées. La release est publiée et l'instance
**Sanctuary Beta** synchronisée, sauvegardes et réglages conservés. Packs
normal/Test et template sont également disponibles dans `build/`.
La version **beta.134** ajoute le **PBR · relief des matériaux** optionnel :
normales procédurales mises en cache, rugosité par matériau et reflets orientés.
Le SSGI reçoit un filtre spatial guidé par la profondeur et des normales de faces
stabilisées. **Options → Shaders** ; réglages existants conservés.
Le [ticket de livraison](docs/shader-beta134.md) décrit le rendu et ses limites.
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.
@@ -38,6 +35,52 @@ La [carte native beta.005](docs/atlas-beta005.md) prend le relais après
l’étude de Xaero et des alternatives libres.
## beta.134 — PBR optionnel et SSGI filtré
Relief et reflets des matériaux ; lissage de la lumière indirecte.
[Contrat et vérifications](docs/shader-beta134.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.134/Sanctuary-beta.134.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.134/Sanctuary-Test-beta.134.mrpack).
## beta.133 — SSGI renforcé
Couleurs voisines plus visibles, même budget de rayons et de cibles GPU.
[Contrat et vérifications](docs/shader-beta133.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.133/Sanctuary-beta.133.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.133/Sanctuary-Test-beta.133.mrpack).
## beta.132 — SSGI : diffusion locale des couleurs
Rebond diffus des couleurs visibles, intensité réglable et reconstruction
préservant les silhouettes. Expérimental, initialement désactivé.
[Contrat et vérifications](docs/shader-beta132.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.132/Sanctuary-beta.132.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.132/Sanctuary-Test-beta.132.mrpack).
## beta.131 — Émissifs indépendants et précision étendue
Bloom, émissifs et highlight activés initialement ; halo facultatif et cinq
précisions communes de 8 à 128 pixels/bloc.
[Contrat et vérifications](docs/shader-beta131.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.131/Sanctuary-beta.131.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.131/Sanctuary-Test-beta.131.mrpack).
## beta.130 — Bloom, minerais émissifs et précision commune
Halo réglable des sources lumineuses et inclusions des vingt textures de
minerais ; réglages par défaut 32/128/16 et ombres activées.
[Contrat et vérifications](docs/shader-beta130.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.130/Sanctuary-beta.130.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.130/Sanctuary-Test-beta.130.mrpack).
## beta.129 — Reflets CTM et ombres jusqu'à 256 blocs
Reflets connectés réglables, cinq portées indépendantes, soleil plein à midi
et correction du réalignement cyclique de la grille d'ombres.
[Contrat et vérifications](docs/shader-beta129.md) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.129/Sanctuary-beta.129.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.129/Sanctuary-Test-beta.129.mrpack).
## beta.128 — Ombres stables, lune et feuillage
Grille de réception stabilisée, contours nets des blocs, atténuation à midi,
+58
View File
@@ -14,6 +14,64 @@ 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.133 — SSGI renforcé
Diffusion des couleurs rendue visible au réglage 35, même budget de rayons
et cibles GPU. [Contrat et vérifications](shader-beta133.md).
[Release beta.133](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.133)
publiée ; canal `9ceb5a5353c9d1b8bb7834a20e818fd65096ff98`. Deux synchronisations isolées,
puis deux dans la même instance Sanctuary Beta réussies. Les 923 fichiers
personnels suivis gardent leurs hashes. Aucun monde personnel ouvert ;
sauvegarde préalable `sanctuary-backups/before-beta.133/`.
Packs normal/Test/template vérifiés dans `sanctuary-beta/build/`.
## beta.132 — SSGI expérimental
Diffusion locale des couleurs visibles, rayon 2,5 blocs, intensité réglable,
initialement OFF. [Contrat et vérifications](shader-beta132.md).
[Release beta.132](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.132)
publiée ; canal `9edaaedc0205c525ecce9de66b5ca7827c56c28f`. Deux synchronisations isolées,
puis deux dans la même instance Sanctuary Beta réussies. Les 923 fichiers
personnels suivis gardent leurs hashes. Aucun monde personnel ouvert ;
sauvegarde préalable `sanctuary-backups/before-beta.132/`.
Packs normal/Test/template vérifiés dans `sanctuary-beta/build/`.
## beta.131 — émissifs indépendants et précision étendue
Bloom, émissifs et highlight ON initialement. Couper le halo ou régler son
intensité à zéro garde les inclusions lumineuses. Précisions 64/128 ajoutées,
défaut 16 conservé. [Contrat et vérifications](shader-beta131.md).
[Release beta.131](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.131)
publiée ; canal `efb18c274d95d988a9566b8c52751469f002cdda`. Deux synchronisations isolées,
puis deux dans la même instance Sanctuary Beta réussies. Les 923 fichiers
personnels suivis sont conservés, préférences et sauvegardes comprises.
Aucun monde personnel ouvert ; sauvegarde `sanctuary-backups/before-beta.131/`.
Archives normal/Test/template vérifiées dans `sanctuary-beta/build/`.
## beta.130 — bloom et précision commune
Bloom réglable, masques sélectifs des vingt textures de minerais, précision
commune aux ombres et au highlight. Réglages initiaux 32/128/16 et Pixel Shadows
activé ; anciennes préférences conservées. [Contrat et vérifications](shader-beta130.md).
[Release beta.130](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.130)
publiée ; canal `68e06353bf880af04c1c72d4e3a159f11f94b619`. Deux synchronisations isolées
puis deux dans la même instance **Sanctuary Beta** réussies. Les 923 fichiers
personnels suivis, réglages de shaders compris, gardent leurs hashes. Aucun
monde personnel ouvert ; sauvegarde préalable `sanctuary-backups/before-beta.130/`.
Packs normal/Test et template vérifiés et copiés dans `sanctuary-beta/build/`.
## beta.129 — reflets CTM et portées 16256 blocs
Reflets connectés entre tous les matériaux coplanaires, portée indépendante,
ombres à 128/256 blocs, intensité rétablie à midi et correction du réalignement
cyclique de la grille lumineuse. [Contrat et vérifications](shader-beta129.md).
[Release beta.129](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.129)
publiée ; canal `2eaa0130a4cd475c598beefdab40f48fc8681ab7`. Deux synchronisations isolées
puis deux dans la même instance **Sanctuary Beta** réussies. Les 923 fichiers
personnels suivis, réglages de shaders compris, sont conservés. Aucun monde
personnel ouvert ; copie préalable dans `sanctuary-backups/before-beta.129/`.
Packs normal/Test et template copiés et vérifiés dans `sanctuary-beta/build/`.
## beta.128 — stabilité des ombres, lune et feuillage
Ombres des blocs nettes et stabilisées, atténuation à midi, lune légère et
+119
View File
@@ -0,0 +1,119 @@
# SHADER-129 — arêtes connectées et portées étendues
Demandes du 17 septembre 2026 : highlight optionnel, puis raccordement CTM de
tous les blocs coplanaires, portée indépendante 16256 blocs, ombres à 128/256,
suppression de l'atténuation au zénith et du réalignement cyclique des ombres.
Branche `codex/edge-highlights-beta129`, socle beta.128, Minecraft 26.3 / Java 25.
## Résultat
Dans **Options → Shaders → Sanctuary · Vanilla Light**, les reflets sur les
arêtes sont désactivés par défaut. L'intensité va de 0 à 100 ; la valeur 30
éclaircit au maximum de 12 %. Leur portée se règle indépendamment des ombres :
**16, 32, 64, 128 ou 256 blocs**. Les anciens réglages restent conservés.
Le highlight raccorde les surfaces plates, y compris entre matériaux différents,
sans dessiner les jonctions intérieures. Il suit les contours visibles du relief,
y compris les dalles et escaliers. Il multiplie la couleur existante pour garder
les pixels des textures et composer avec les ombres. Ce CTM géométrique utilise
la profondeur déjà produite par Minecraft : aucune liste de blocs autorisés,
aucun pack CTM à fournir, aucune exploration CPU du volume de 256 blocs.
Une cassure/repose modifie le contour dès que le terrain natif est réaffiché.
Le trait proche suit la grille de 1/16 de bloc. À distance, la couverture devient
partielle pour limiter le scintillement des traits plus petits qu'un pixel.
La portée choisie s'efface progressivement dans son dernier quart et respecte
la brume ainsi que la distance de rendu. Une passe plein écran, quatre sondes
de profondeur et un tampon uniforme de 176 octets ; aucun retour GPU vers CPU
en production. L'arrêt de l'option ou une intensité nulle libèrent ce tampon.
Ce n'est pas un remplacement des textures par des variantes CTM. Le calcul
concerne les faces visibles alignées sur la grille : les faces hors écran,
les surfaces transparentes absentes de la profondeur et les géométries
arbitrairement inclinées ne sont pas reconstruites. À très grande distance,
les détails plus petits qu'un pixel peuvent rester imperceptibles. Aucun
chargement de chunk forcé, aucune promesse de FPS.
Les ombres proposent les cinq mêmes portées. La carte reste plafonnée à
4096 × 4096 : les grandes distances réduisent sa précision spatiale. Le cache
garde ses limites de 96 Mio, quatre sections et 3 ms souples de construction
par image ; la réconciliation des chunks est limitée aux ticks et changements
de caméra/terrain plutôt qu'à toutes les images.
L'intensité solaire reste pleine au zénith. Les contours opaques restent nets,
le filtrage du feuillage et les ombres légères de la lune sont conservés.
Le réalignement de la grille lumineuse a lieu au changement d'ancrage de la
caméra, avec transfert de phase par texels entiers. La rotation du soleil ne
réarrondit plus la projection de l'origine mondiale à chaque image, source
de sauts périodiques amplifiés aux grandes coordonnées. Le soleil continue
d'avancer ; une ombre pixélisée peut naturellement changer de case. Cette
correction ne modifie pas l'horloge serveur ni sa cadence native.
Textes FR/EN. Aucun changement de règles, sauvegardes, génération, gemmes,
sculptures ou découvertes.
## Vérifications et livraison
Le client natif réussit en **2 min 41 s**, dans un monde neuf plat, graine 122,
sur macOS / Apple M1 / OpenGL :
- CTM : 596 échantillons éclaircis sur le contour et 65 068 conservés ; les
20 centres et 20 jonctions coplanaires ciblés restent intacts, y compris
au changement de matériau. L'intensité 90 produit environ trois fois le gain.
- Ouverture d'un trou : 716 échantillons éclaircis ; la fermeture restitue
l'image connectée. Au-delà de 16 blocs, le réglage 16 supprime le contour ;
le réglage 64 le rétablit (254 échantillons éclaircis).
- Cinq portées CTM et cinq portées d'ombres, persistance/clamp, migration des
anciennes préférences, arrêt/libération, rechargement, caméra et vue oblique
avec ombres. Captures françaises/anglaises inspectées ; suppression ensuite
d'un préfixe redondant dans le libellé de portée.
- Soleil, lune, nouvelle lune, ombres opaques nettes à midi et transmission
du feuillage restent vérifiés. Casters hors écran et retrait/repose actifs.
- 2 000 petits pas solaires aux coordonnées ±100 000 vérifient l'absence de
saut de translation de la carte ; changement d'ancrage par texels entiers.
La séquence du vrai service realtime suit 294 images : angle avancé de
0,000694 radian, aucun rebond dans les 65 664 échantillons de sol suivis.
Sur cette courte séquence, aucune case opaque n'a changé ; cela ne constitue
pas une mesure de mouvement visible sur une journée entière.
La fixture initiale capturait parfois le terrain avant la fin de sa construction
native : elle attend désormais la réception de toute la plateforme et la fin
compilée des sections. Aucun changement de gameplay pour contourner ce problème.
Commande : `./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true
-PsanctuaryPixelShadows122ClientTests=true -PsanctuaryClientNoVsync=true
-PsanctuaryQuickTests=true`. Log `build/shader129-client.log`, captures sous
`mods/sanctuary/build/run/clientGameTest/screenshots/`.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussit en **2 min 17 s**, 126 tâches. Une dernière passe dassemblage intègre
la simplification du libellé de portée (2 min 18 s). Les archives sont vérifiées :
**17 entrées de production** diffèrent de beta.128, limitées au shader et à ses
interfaces. Même JAR dans normal/Test, sources archivées conformes, ressources
du template exactes, assets des gemmes inchangés. Aucun monde ni test embarqué.
Le serveur GameTest dédié
reste exclu conformément au refus antérieur de son EULA. Aucun monde personnel
utilisé pour les essais, aucun résultat Windows/Vulkan revendiqué.
Archives vérifiées dans `build/` et recopiées dans le dossier de livraison
habituel `sanctuary-beta/build/` :
- Normal : 10 353 946 octets, SHA-256
`2c7735194faab8a699004e69ad543ce6e8fabdebe297b759bffd31fea2dcc2e8`.
- Test : 10 372 872 octets, SHA-256
`cca572ee313d1d7e66ed745bb48bac62490998760d9b7bbfc7a1f6d60c430dce`.
- JAR Sanctuary :
`ca3e2ff4e0717a3a54c1d57478d43aade39d001b71c4eec745d76f010f7def54`.
[Release beta.129](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.129)
publiée depuis `35103a7ea8635dc4ed26293f00e86d99aaacfca8`. Canal packwiz
`2eaa0130a4cd475c598beefdab40f48fc8681ab7`. Deux synchronisations isolées puis deux synchronisations
dans l'instance **Sanctuary Beta** ont réussi. Les **923 fichiers personnels**
suivis, dont les réglages de shader, sont inchangés. Aucun monde personnel ouvert.
Sauvegarde préalable des fichiers gérés : `sanctuary-backups/before-beta.129/`.
Le JAR installé a le hash vérifié ci-dessus. Reçus locaux :
`build/shader129-artifact.json`, `build/shader129-isolated.json` et
`build/shader129-prism.json`. Le checkout initial avec ses modifications en
cours a été préservé ; seule la copie des archives versionnées rejoint son
répertoire `build/`.
+131
View File
@@ -0,0 +1,131 @@
# SHADER-130 — bloom et précision commune
Demandes du 17 septembre 2026 : bloom réglable du soleil et des sources
lumineuses ; inclusions émissives du fer, cuivre, redstone, lapis, diamant,
émeraude, rubis, saphir, quartz et or ; réglage commun « Précision » pour
les ombres et le highlight. Branche `codex/bloom-beta130`, socle beta.129,
Minecraft 26.3 / Java 25.
## Résultat
Dans **Options → Shaders → Sanctuary · Vanilla Light**, le bloom possède
un interrupteur, une intensité et une diffusion réglables de 0 à 100.
Les minerais ont leur propre interrupteur et intensité. Le bloom reste
optionnel, désactivé initialement, avec une intensité préparée à 35, une
diffusion à 50 et une émission des minerais à 50. Une intensité globale
nulle arrête les passes et libère les ressources de cet effet.
Les nouveaux réglages par défaut sont **Pixel Shadows activé**, distance
des ombres **128 blocs**, highlight **32 blocs** et **Précision 16 pixels
par bloc**. La précision unique propose 8, 16 et 32 et contrôle simultanément
ombres et arêtes. La clé JSON historique `shadowPixels` est conservée :
les préférences déjà personnalisées ne sont pas écrasées.
Les vingt textures de minerais sont analysées depuis les ressources actives.
La palette de pierre, de deepslate ou de netherrack est comparée aux pixels
colorés des inclusions, avec un traitement du quartz et de l'or du Nether.
Les reflets clairs accolés aux inclusions sont conservés. Le masque est
calculé une fois par texture et conservé en cache jusqu'au rechargement des
ressources. Une texture optionnelle `<nom>_e.png` peut préciser ce masque
pour une texture candidate ; sa luminance maximale multipliée par l'alpha
remplace la détection automatique. Les couleurs affichées viennent toujours
de l'atlas Minecraft actif, y compris son animation.
Les sources sont les faces visibles des modèles de blocs lumineux, la lave,
le soleil et la lune. Les lampes éteintes ne produisent pas de halo. Le
niveau lumineux natif pondère les sources ordinaires ; l'émission des minerais
est purement graphique. La profondeur du terrain élimine les inclusions
cachées et masque le soleil derrière les constructions. Le cœur émissif
conserve ses pixels nets, tandis qu'un halo séparé est diffusé à résolution
réduite puis composé sur l'image.
Aucun niveau de lumière serveur, spawn, règle de jeu, format de sauvegarde,
identifiant ou paramètre de génération ne change. Aucun monde personnel
n'est ouvert pour les essais.
## Coût et limites
La géométrie suit uniquement les sections natives visibles et déjà chargées.
Leur palette permet d'écarter rapidement les sections sans source ; le cache
invalide les sections touchées lors des modifications. Construction répartie
sur les images, au plus quatre sections démarrées et un budget souple de
3 ms, cache GPU plafonné à 32 Mio et 16 384 faces par section. Une section
qui dépasse les limites est omise. Aucun chargement de chunk forcé.
Le masque GPU est plafonné à 256 textures, chacune échantillonnée au plus en
64 × 64 ; les textures natives 16 × 16 conservent leurs texels. Les masques
animés utilisent leur première image, tandis que la couleur reste animée.
La carte d'émission est à la résolution de l'écran ; la diffusion séparable
utilise deux cibles flottantes à 1/8 de largeur et de hauteur, sans retour
GPU vers CPU en production. Les allocations sont libérées à l'arrêt, au
rechargement et à la déconnexion, et recréées au changement de taille.
Cette première version ne reconstruit pas les sources hors écran, les
particules, les entités lumineuses ni les modèles dessinés exclusivement par
un renderer de block entity. Les packs très différents peuvent demander un
masque explicite. Les silhouettes des liquides sont approximées puis
masquées par la profondeur native. Aucune promesse de FPS ni validation
Windows/Vulkan ; les essais graphiques portent sur macOS / Apple M1 / OpenGL.
## Vérifications
Le test client ciblé réussit en **1 min 29 s**, dans un nouveau monde plat,
graine 122. Il analyse les vingt textures, refuse les pixels gris de leur
roche, mesure le gain nocturne et les variations réelles d'intensité/diffusion.
Couvrir les minerais de pierre supprime toute source émissive. Retirer les
blocs rétablit l'émission ; le rechargement des ressources fait de même.
Torche, glowstone, lampe alimentée, lave et soleil sont vérifiés, ainsi que
l'arrêt de la lampe et l'occultation du soleil par de la pierre. Les valeurs
par défaut et la conservation d'anciens choix personnalisés passent.
Log ciblé : `build/shader130-client-bloom.log` ; masques inspectables dans
`mods/sanctuary/build/run/clientGameTest/bloom130-ore-masks.png` et son CSV.
La suite shader complète réussit en **3 min 16 s** : casters hors écran,
retrait/repose, trois précisions, cinq portées, lune, zénith, feuillage,
realtime, caméra et rechargement restent vérifiés. Les arêtes CTM restent
raccordées entre matériaux ; 1 295 échantillons du contour sont éclaircis
à précision 8, 596 à 16 et 120 à 32. Les captures françaises/anglaises et
le rendu des minerais/du soleil sont inspectés.
Commandes natives : `./gradlew :sanctuary:runClientGameTest
-PsanctuaryClientTests=true -PsanctuaryPixelShadows122ClientTests=true
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true`. Le test ciblé utilise
`-PsanctuaryBloom130ClientTests=true` à la place de la propriété PixelShadows.
Log complet : `build/shader130-client-full.log` ; captures sous
`mods/sanctuary/build/run/clientGameTest/screenshots/`.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussit en **2 min 24 s**, 126 tâches. Le serveur GameTest dédié reste exclu
conformément au refus antérieur de son EULA. La comparaison au binaire beta.129
trouve **24 entrées de production** modifiées, toutes limitées au shader et à
ses interfaces. Gameplay et textures des gemmes inchangés ; aucun test,
monde ou modèle GLB embarqué. Les sources archivées correspondent aux sources
Java, les packs normal/Test embarquent le même JAR et le template contient
exactement les ressources prévues.
Archives vérifiées dans `build/` et recopiées dans le dossier habituel
`sanctuary-beta/build/` :
- `Sanctuary-beta.130.mrpack` : 10386021 octets, SHA-256
`7a3fcd4e496fa136eb3e0896fa7b856ae91771123186fa8df13c8fb1e0cf3e9a`.
- `Sanctuary-Test-beta.130.mrpack` : 10404944 octets, SHA-256
`8de094fcbc58a300cc24d64c8d1b7931fdb8db354c75defa6bf2a2aa4a123a23`.
JAR : `1ac4d1b1339361fb665f03ca3bd359163db5037ffd90e684c5a461759f6076e7`.
[Release beta.130](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.130)
publiée depuis `534dfec4f88169a200702b50d1ba87d85c107bee`. Canal packwiz
`68e06353bf880af04c1c72d4e3a159f11f94b619`. Les téléchargements publics sont vérifiés par SHA-256.
Deux synchronisations isolées, puis deux dans la même instance **Sanctuary
Beta**, réussissent. Un seul JAR Sanctuary beta.130 est actif. Les
**923 fichiers personnels suivis** conservent leurs hashes, dont les mondes,
packs et préférences de shaders. Sauvegarde préalable dans
`sanctuary-backups/before-beta.130/` de l'instance. Aucun monde personnel ouvert.
Reçus locaux ignorés : `build/shader130-artifact.json`,
`build/shader130-isolated.json`, `build/shader130-prism.json` et
`build/shader130-publication.log`. Le checkout original conserve ses
modifications préexistantes ; seuls les trois artefacts versionnés vérifiés
sont recopiés dans son dossier `build/`.
+91
View File
@@ -0,0 +1,91 @@
# SHADER-131 — émissifs indépendants et réglages initiaux
Demande : activer par défaut bloom, minerais émissifs et highlight ; garder
les émissifs avec halo coupé ou intensité du bloom à zéro, sans bouton ajouté ;
ajouter 64 et 128 pixels/bloc en options. Précision initiale 16, distances
highlight 32 et ombres 128 conservées. Préférences personnalisées préservées.
Branche `codex/emissive-beta131`, socle beta.130, Minecraft 26.3 / Java 25.
Le futur effet demandé est une légère teinte diffusée depuis les blocs
voisins, pas des reflets reconnaissables. Étude seulement pour ce ticket ;
aucun SSR ou éclairage indirect expérimental activé.
## Comportement et validation
Le curseur à zéro et l'interrupteur Bloom coupent uniquement le halo.
Le réglage existant des minerais contrôle leurs inclusions nettes séparément.
Sans halo, les cibles de diffusion GPU sont libérées ; l'émission garde
seulement les ressources nécessaires. Tout désactiver libère l'ensemble.
La grille commune propose 8, 16, 32, 64 et 128, sans augmenter le plafond
4096 de la carte d'ombres. Libellés FR/EN et essais clients natifs sur les
inclusions, l'absence de halo, les transitions et les nouvelles précisions.
Aucun changement de lumière serveur, de génération ou de sauvegardes.
Le cœur émissif et le halo utilisent maintenant deux chemins indépendants.
Les textures et leur analyse restent celles de beta.130. Avec le halo coupé,
la carte d'émission est effacée avant les faces, le cœur net est composé,
et les passes de ciel, réduction, diffusion et application du halo sont
omises. Le retour au bloom recrée ses cibles. Aucun bouton ajouté, aides
FR/EN adaptées et choix explicitement désactivés toujours conservés.
## Vérifications
Suite client native réussie en **3 min 46 s**, monde plat neuf de graine 122,
macOS / Apple M1 / OpenGL. Cinq précisions sauvegardées et chargées, bornes
et seuils de normalisation, carte d'ombres plafonnée et arêtes CTM rendues
à 64/128. Le gain mesuré des traits diminue de 0,02778 à précision 32 à
0,02094 à 64, puis 0,01013 à 128 dans la même scène.
Les inclusions restent nettes sans halo : le rendu Bloom OFF correspond
au rendu intensité zéro. Aucun gain mesuré hors du masque source, ressources
de diffusion absentes ; couper aussi les émissifs libère l'ensemble et
restitue l'image initiale. Réactiver le bloom restitue son image précédente.
Captures OFF/zéro inspectées. Valeurs initiales ON vérifiées ; un ancien
choix explicitement OFF reste OFF. Les essais beta.130 (vingt masques,
occultation, sources, rechargement), lune, feuillage et realtime passent.
Le premier essai rencontrait une assertion historique attendant le highlight
OFF lorsqu'une clé était absente. Elle attend désormais la nouvelle valeur
ON ; les cas de préférences explicitement désactivées restent testés.
Commande : `./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true
-PsanctuaryPixelShadows122ClientTests=true -PsanctuaryClientNoVsync=true
-PsanctuaryQuickTests=true`. Log : `build/shader131-client-full.log` ; captures
sous `mods/sanctuary/build/run/clientGameTest/screenshots/`.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussit en **2 min 30 s**, 126 tâches. Les archives normal/Test, leurs sources
et le template sont vérifiés. Six entrées de production changent : moteur
Bloom, préférences, interface et libellés. Gameplay, shaders GLSL et textures
restent identiques à beta.130. Aucun résultat Windows/Vulkan
revendiqué ; le serveur GameTest dédié reste exclu conformément au refus
antérieur de son EULA.
## Piste suivante — diffusion des couleurs
Choix confirmé : légère teinte des surfaces voisines. Une estimation SSGI
utilise la couleur et la profondeur visibles pour des rebonds diffus ; voir
la [documentation primaire Unity](https://docs.unity3d.com/Packages/com.unity.render-pipelines.high-definition@17.0/manual/Override-Screen-Space-GI.html).
Proposition à expérimenter : faible intensité, courte portée autour des
surfaces, calcul réduit, rejet par profondeur pour ne pas teinter au travers
des murs. Vérifier les bords de l'écran, les blocs hors champ, la stabilité
en mouvement et les textures avant de fixer une qualité. Cette piste n'est
pas implémentée dans beta.131.
## Livraison
- `Sanctuary-beta.131.mrpack` : 10386312 octets ; SHA-256 `e02067e424bf4f54512d5cfb1b5cde0fe232aa6bdadc0d38981cd15817a6387a`.
- `Sanctuary-Test-beta.131.mrpack` : 10405238 octets ; SHA-256 `298387cce5a5b7cdc09b3a9d24859b674a736b25abcbafa7995b813757641265`.
JAR : `87210da23f7781efccdbdbf5d4e2d3c1ff1857a7f326027b93d6a301c7dfdb0d`.
Archives vérifiées recopiées dans `sanctuary-beta/build/`. [Release beta.131](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.131)
publiée depuis `078d3e6833daa7de7f6c6d0e1a6c17d5dbefe075`. Canal packwiz
`efb18c274d95d988a9566b8c52751469f002cdda`. Téléchargements publics vérifiés par hash.
Deux synchronisations isolées, puis deux dans la même instance **Sanctuary Beta**
réussissent. Un seul JAR Sanctuary beta.131 ; les **923 fichiers personnels
suivis** conservent leurs hashes, dont les préférences de shaders et les mondes.
Sauvegarde préalable `sanctuary-backups/before-beta.131/` ; aucun monde personnel
ouvert. Reçus ignorés : `build/shader131-artifact.json`,
`build/shader131-isolated.json`, `build/shader131-prism.json` et
`build/shader131-publication.log`. Le checkout original conserve ses changements.
+106
View File
@@ -0,0 +1,106 @@
# SHADER-132 — diffusion locale SSGI expérimentale
Demande : légère teinte diffusée des surfaces voisines, sans reflets miroir
ni shader d'eau. Branche `codex/ssgi-beta132`, socle beta.131, Minecraft 26.3 /
Java 25. Aucun changement de génération, sauvegarde ou lumière serveur.
## Résultat
**Options → Shaders → SSGI · couleurs diffusées** active un rebond diffus
local. L'effet expérimental est initialement OFF, avec une intensité préparée
à **35 %** ; le curseur propose 0100. À zéro ou OFF, ses cibles et uniformes
GPU sont libérés. Les autres effets et leurs préférences sont conservés.
La couleur d'une surface visible peut teinter les surfaces proches qui lui
font face. Le calcul porte à **2,5 blocs** du récepteur et s'estompe entre
24 et 32 blocs de la caméra. Il utilise une copie de la couleur native après
les ombres, avec la profondeur et des normales reconstruites. Le premier
obstacle arrête chaque rayon, même si son épaisseur empêche de retenir une
intersection. Les plans coplanaires ne s'éclairent pas eux-mêmes.
Douze directions hémisphériques fixes, au maximum dix étapes par rayon,
sont évaluées à demi-résolution en largeur et hauteur. La reconstruction
plein écran est guidée par la profondeur pour conserver les silhouettes et
les détails des textures. Le cœur des émissifs, le highlight et le bloom
sont composés ensuite : pas de halo réinjecté dans le rebond ni de boucle
de rétroaction d'une image sur l'autre. Aucune lecture GPU vers CPU ni
recherche de chunks en production. Pas de bruit temporel ou d'historique.
Deux cibles, RGBA8 plein écran et RGBA16 flottant à demi-résolution, soit
environ six octets par pixel de l'écran, plus 176 octets d'uniformes par
segment du tampon natif. Redimensionnement, arrêt, déconnexion et
rechargement libèrent/recréent les ressources possédées. Ce budget n'est
pas une garantie de FPS ; le SSGI coûte davantage qu'un simple réglage de
colorimétrie.
## Limites de cette version
L'estimation utilise seulement la couleur et la profondeur visibles. Les
blocs hors champ ou cachés n'apportent pas leur couleur. Le résultat peut
changer lorsqu'ils entrent ou sortent de l'image ; une atténuation près
des bords limite la coupure. Les surfaces transparentes absentes de la
profondeur ne sont pas reconstruites. Petits objets et intersections plus
minces que l'échantillonnage peuvent manquer. Il s'agit d'une approximation
diffuse locale, pas d'une simulation complète d'éclairage global ni de
reflets spéculaires. Aucune modification de l'eau et aucune source hors
champ déduite ou générée.
## Vérifications
Test ciblé natif réussi en **1 min** sur macOS / Apple M1 / OpenGL, nouveau
monde plat de graine 122. À intensité 90, les échantillons du sol blanc
montrent un gain rouge cumulé de 8 968 contre 20 bleu devant le mur rouge,
puis 9 168 bleu contre 23 rouge devant le mur bleu. Le sol seul est inchangé.
OFF/zéro restituent l'image de base et libèrent les cibles ; intensité 20
réduit le gain. Changer la couleur du mur complètement occulté laisse
l'image inchangée. Rechargement, caméra fixe, mouvement infinitésimal et
retour à la caméra d'origine passent, sans accumulation de couleur.
Deux ajustements de fixture ont précédé ce résultat : inclure dans les
mesures le blanc légèrement bleuté par l'éclairage natif, et formater les
petits angles de `/tp` en décimal car la commande refuse la notation
scientifique. Aucun comportement du jeu modifié pour ces corrections.
Log ciblé : `build/shader132-client-ssgi.log`. La suite complète réussit en
**4 min 18 s** et vérifie aussi une fenêtre 961 × 541, le retour à sa taille
initiale, le SSGI avec ombres/highlight/bloom/émissifs actifs, ainsi que
l'arrêt global du shader. Les captures françaises et anglaises passent.
Les contrôles précédents des cinq précisions, ombres, lune, feuillage, CTM,
bloom et émissifs restent réussis. Log : `build/shader132-client-full.log`.
Commandes : `./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true
-PsanctuaryPixelShadows122ClientTests=true -PsanctuaryClientNoVsync=true
-PsanctuaryQuickTests=true`. Pour le test ciblé, remplacer la propriété
PixelShadows par `-PsanctuarySsgi132ClientTests=true`.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussit en **2 min 25 s**, 126 tâches. Les packs normal/Test contiennent le
même JAR ; sources archivées et template de ressources correspondent aux
sources. Comparaison avec beta.131 : **11 entrées de production** modifiées,
limitées au SSGI, à ses options et à ses points d'appel. Gameplay et textures
des gemmes inchangés, aucun monde ni test embarqué. Aucun résultat
Windows/Vulkan revendiqué.
Le serveur GameTest dédié reste exclu conformément au refus antérieur de
son EULA. Les captures panoramiques et les milieux de brume sous l'eau/lave
ou neige poudreuse suspendent cette passe, comme les autres effets de
profondeur Sanctuary.
## Livraison
- `Sanctuary-beta.132.mrpack` : 10394026 octets, SHA-256 `a7c0823e00f36925919645e32e7792242ec1d01015b41bee5b4f6285b9fc533d`.
- `Sanctuary-Test-beta.132.mrpack` : 10412949 octets, SHA-256 `9bc30a7d6d5834f9323f15f0c39d3f49d200e1a97d739266ab373c9e1ba4a8fd`.
JAR : `83eb2d091a7acd85eaa8b3b6a00a40ea483f9eeb2a30c033bdf68b4d717c5142`.
Archives vérifiées recopiées dans `sanctuary-beta/build/`. [Release beta.132](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.132)
publiée depuis `7e21781a9a08c669c7d6b65653bd9d408887689c`. Canal packwiz
`9edaaedc0205c525ecce9de66b5ca7827c56c28f` ; téléchargements publics vérifiés par SHA-256.
Deux synchronisations isolées, puis deux dans la même instance **Sanctuary Beta**,
réussissent. Un seul JAR beta.132 actif ; les **923 fichiers personnels suivis**,
dont sauvegardes et préférences de shaders, gardent leurs hashes. Sauvegarde
préalable `sanctuary-backups/before-beta.132/` ; aucun monde personnel ouvert.
Le checkout original conserve ses changements préexistants.
Reçus locaux ignorés : `build/shader132-artifact.json`,
`build/shader132-isolated.json`, `build/shader132-prism.json`,
`build/shader132-publication.log`.
+106
View File
@@ -0,0 +1,106 @@
# SHADER-133 — SSGI visible au réglage courant
Demande : le rebond diffus de beta.132 est trop discret pour être distingué.
Branche `codex/ssgi-strength-beta133`, socle beta.132, Minecraft 26.3 / Java 25.
## Changement
La collecte conserve douze directions, dix étapes, une demi-résolution et
un rayon de 2,5 blocs. L'atténuation spatiale devient linéaire plutôt que
quadratique. Le mélange est reconstruit en lumière approximativement linéaire,
puis réencodé une seule fois. Un gain artistique plus fort rend la couleur
lisible et une courbe progressive évite de couper brutalement les canaux
à blanc. Les surfaces sans rebond restent intactes. La force demeure réglable
de 0 à 100, avec le même réglage initial de 35 ; aucune préférence enregistrée
n'est écrasée. SSGI reste optionnel, initialement désactivé.
Aucun rayon, cible GPU ou texture de matériau supplémentaire. La composition
ajoute une exponentielle et une racine carrée par canal ; le coût exact en FPS
reste à mesurer sur la machine du joueur. Les normales de géométrie orientent
déjà le rebond. Pas de PBR ni de normales procédurales de textures ajoutés.
## Piste matériaux
Pour une étape distincte, des normales de faible amplitude calculées au
chargement des textures, mises en cache, et une rugosité par famille de matériaux
permettraient d'étudier du relief et des reflets orientés. Une simple conversion
luminosité → hauteur ne connaît pas le relief réel : un motif peint peut devenir
une bosse artificielle. Commencer sur pierre, briques et bois avec une amplitude
réglable serait préférable à une génération aveugle pour tous les blocs.
La passe actuelle ne possède ni atlas de normales, ni identifiants de matériaux,
ni albédo non éclairé ; des images seules ne suffiraient pas à les exploiter.
## Limites conservées
Approximation diffuse à partir de la couleur déjà éclairée, pas un transport
physique complet. Seules les surfaces visibles contribuent, avec variations
possibles aux entrées/sorties d'écran. Premier obstacle bloquant, pas d'historique
ni de bruit temporel ; aucune modification d'eau, monde ou lumière serveur.
## Vérifications
Suite client native complète réussie en **4 min 29 s**, sur macOS / Apple M1 /
OpenGL, dans un monde de test plat (graine 122). Les comparaisons ON/OFF à 35 %
mesurent, sur les pixels neutres du sol :
| Mur | Pixels avec gain ≥ 8/255 | Pixels avec gain ≥ 16/255 | Gain cumulé du canal dominant |
| --- | ---: | ---: | ---: |
| Rouge | 16 487 | 7 311 | 305 221 |
| Bleu | 14 954 | 7 328 | 309 991 |
À 90 %, les gains dominants atteignent 654 683 et 655 867, contre 8 968 et
9 168 au même réglage dans la fixture beta.132. Ce rapport concerne uniquement
cette scène et ces pixels, pas une promesse de gain uniforme dans tous les mondes.
Les captures confirment une bande colorée au pied du mur. Un premier réglage
intermédiaire a été rejeté car aucun pixel du sol ne dépassait 8/255 à 35 % ;
un second a passé les contrôles ciblés, puis la force a encore été augmentée
après examen visuel. Les valeurs finales sont celles de la suite complète.
Le test impose maintenant plus de 5 000 pixels à 8/255 et plus de 500 à 16/255
au réglage 35, pour les deux couleurs. Intensité, OFF/zéro, absence de rebond
coplanaire, occultation d'un mur changeant de couleur, caméra fixe, déplacement
infinitésimal, retour de caméra, redimensionnement et rechargement passent.
Ombres, lune, feuillage, CTM, cinq précisions, bloom, émissifs indépendants et
interfaces FR/EN restent validés, y compris avec le SSGI actif.
Commande : `./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true
-PsanctuaryPixelShadows122ClientTests=true -PsanctuaryClientNoVsync=true
-PsanctuaryQuickTests=true`. Log : `build/shader133-client-full.log`.
Le nom de la propriété ciblée `sanctuarySsgi132ClientTests` est conservé.
Comparateur local autonome : `build/SSGI-beta.133-comparaison.html`, captures
natives OFF/35/90 des murs rouge/bleu, sans retouche. Pas de validation
Windows/Vulkan ni de mesure de coût FPS revendiquée.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussit en **2 min 21 s**, 126 tâches. Le serveur GameTest dédié reste exclu
conformément au refus EULA antérieur. Log : `build/shader133-build.log`.
Les archives normal/Test contiennent le même JAR ; sources archivées et template
de ressources correspondent aux sources. Comparaison avec beta.132 : **5 entrées
de production** changées (classe du compositeur, deux shaders, libellés FR/EN).
Gameplay, textures, sauvegardes et dépendances inchangés.
## Artefacts
- `Sanctuary-beta.133.mrpack` : 10394282 octets, SHA-256 `0c90a070da7f969706abeeec1bdfb0830bb52244c20fccbca4fd876f3fbe0564`.
- `Sanctuary-Test-beta.133.mrpack` : 10413204 octets, SHA-256 `e01f0355bf2cce18156fe94a069e142bedc498624ba0b6e2729ae0f23ea73e80`.
JAR : `a01cae4a98606007b533ca09ec9f072f19a1db2e9b8de466f4ad075d5c1fc185`.
## Livraison
[Release beta.133](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.133)
publiée depuis `216d3f75b576bd2ed531653012f9cc6c4801d87a`. Canal packwiz
`9ceb5a5353c9d1b8bb7834a20e818fd65096ff98` ; téléchargements publics vérifiés par SHA-256.
Deux synchronisations isolées puis deux synchronisations de la même instance
**Sanctuary Beta** réussissent. Un seul JAR beta.133 actif ; les **923 fichiers
personnels suivis**, dont sauvegardes et préférences, gardent leurs hashes.
Sauvegarde préalable `sanctuary-backups/before-beta.133/`, aucun monde personnel
ouvert. Le checkout original conserve ses changements préexistants.
Les archives normal/Test/template et le comparateur natif sont également dans
`sanctuary-beta/build/`. Reçus locaux ignorés : `build/shader133-artifact.json`,
`build/shader133-isolated.json`, `build/shader133-prism.json` et
`build/shader133-publication.log`.
+93
View File
@@ -0,0 +1,93 @@
# SHADER-134 — PBR procédural optionnel et herbe SSGI
Demande : ajouter en option des normales calculées à partir des textures actives,
avec rugosité des matériaux et reflets orientés. Retour : le SSGI renforcé
éclaire artificiellement l'intérieur de l'herbe ; vérifier et corriger ce cas.
Branche `codex/pbr-beta134`, socle beta.133, Minecraft 26.3 / Java 25.
## Contrat
PBR initialement OFF, intensité 50 réglable 0100. Cartes de normales à partir
des différences de luminance, amplitude limitée ; rugosité/métallicité par
famille de blocs, repli rugueux pour les types inconnus. Analyse des ressources
actives conservée en cache jusqu'à désactivation ou rechargement ; pas de fichiers
écrits dans le pack personnel, pas de GPU readback en production.
Portée 32 blocs, sections visibles construites progressivement. Budget de maillage
24 Mio, atlas de 4 096 matériaux en 16 × 16 avec bordures (1 280 × 1 280 RGBA8,
6,25 Mio), copie RGBA8 de l'image courante et uniformes. Les textures de plus haute
résolution sont échantillonnées à 16 × 16 ; première image des textures animées.
La couleur et l'animation restent celles de l'atlas natif. Pas de parallax ni
de géométrie ajoutée aux blocs ; les motifs clairs peuvent donner un faux relief.
La passe utilise une réponse spéculaire GGX (normales, rugosité, métallicité,
Fresnel) et module l'éclairage diffus déjà rendu. Elle ne remplace pas tout le
pipeline Minecraft par un moteur PBR physique. Soleil/lune, exposition au ciel et
carte d'ombres active limitent les reflets ; pas de reflets miroir, d'environnement
SSR, ni de direction locale déduite pour chaque torche. Aucun rendu PBR d'eau,
d'entités, d'items ou de modèles spéciaux hors maillage de terrain.
## SSGI
Les normales des faces presque axiales sont stabilisées sur leur axe. Deux passes
spatiales filtrent la lumière indirecte à demi-résolution, avec rejet des voisins
hors du plan du récepteur et interpolation de l'échantillonnage. Les textures de
l'image restent nettes. Un tampon RGBA16F supplémentaire à demi-résolution porte
le budget des cibles SSGI à environ 8 octets par pixel plein écran (contre 6).
Pas d'historique temporel ni de rémanence ajoutés.
Un parcours de 12 blocs a reproduit des bandes sur un mur et un éclaircissement
artificiel d'herbe à 35 et 100. Le filtrage seul ne suffisait pas : la collecte
réinjectait aussi trop fortement la lumière ambiante neutre. Elle conserve
maintenant la composante chromatique de la source, en soustrayant son canal
linéaire minimum. L'éclairage ambiant neutre reste celui de Minecraft. Ce réglage
vise le transfert de couleur voulu, pas une simulation physique complète du
rebond blanc. Les sources hors champ/cachées restent absentes du SSGI ; le résultat
peut encore varier lors d'un déplacement important. PBR et SSGI restent indépendants.
## Vérification
Test PBR ciblé réussi en 1 min 13 s : relief éclairé/ombré, intensités 20/50/100,
soleil matin/soir, cache stable, préférences, OFF/zéro, mouvement infinitésimal,
redimensionnement, rechargement et combinaison des effets. Le premier parcours
SSGI est conservé comme reproduction ; résultats finaux ci-dessous.
Le parcours corrigé a réussi en **2 min 47 s** avec 13 positions réparties sur
12 blocs, à 35 et 100, chaque fois comparées à OFF. Un mob immobile, des brins
d'herbe et un mur figurent dans la scène. Retour au point initial sans rémanence.
Sur les captures de ce parcours, le maximum du gain vert moyen mesuré dans
l'herbe passe sous 9,73 niveaux à 35 et 24,51 à 100. Le test impose ensuite
respectivement des limites de 12 et 30 à chaque position. L'essai reproduisant
le défaut atteignait notamment 23,68 et 49,20 à l'extrémité droite.
Ces mesures décrivent cette scène, pas une garantie d'absence d'artefacts
dans tous les mondes ni sur toutes les cartes graphiques.
La suite client complète réussit en **7 min** sur macOS / Apple M1 / OpenGL.
Elle inclut le parcours latéral et ses seuils de gain, les deux couleurs de rebond,
le PBR, les ombres, le feuillage, les cinq précisions, le CTM, les émissifs, le
bloom, les préférences et les écrans FR/EN. Log : `build/shader134-client-full.log`.
Le contrôle PBR final réussit en **1 min 19 s** : placement/retrait d'un toit,
invalidations de lumière/maillage et rendu combiné avec les ombres actives. Les
reflets utilisent maintenant la grille des ombres pour leur test de visibilité. Aucun résultat Windows/Vulkan ni
mesure de FPS revendiqués.
## Construction et artefacts
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussit en **2 min 22 s**, 126 tâches. Le serveur GameTest dédié reste exclu
conformément au refus EULA antérieur. Log : `build/shader134-build.log`.
Packs normal/Test : même JAR Sanctuary. Sources archivées et template identiques
aux sources. Comparaison à beta.133 : **23 entrées de production** changées,
limitées au rendu, à ses options et points d'appel. Gameplay et gemmes inchangés.
- `Sanctuary-beta.134.mrpack` : 10421971 octets, SHA-256 `d5bc79acb1d56e7dcf83c976156fdb2fcde80c98e443bde8a6ab312d56887b7f`.
- `Sanctuary-Test-beta.134.mrpack` : 10440898 octets, SHA-256 `04308ebb9adaf76b30a3cbf4fa32ab897e76842803fc8c8411cfa32952d1bdd5`.
JAR : `a6e69622a5e6629f70dd19b7494ad3b00916bc02169b73635f0e2d563929b7fa`.
Comparateur local : `build/Shader-beta.134-comparaison.html`. Il contient les
captures natives PBR OFF/50/100 et le parcours SSGI de 12 blocs OFF/35/100.
Le parcours est une succession de positions enregistrées, pas une mesure des FPS.
+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.128
pack_version=beta.128
mod_version=beta.134
pack_version=beta.134
resource_pack_version=beta.121
maven_group=fr.koka.sanctuary
jei_version=30.32.0-sanctuary.3
+11
View File
@@ -93,6 +93,17 @@ tasks.named('runGameTest') {
}
tasks.named('processGametestResources') {
def pbr134Client = providers.gradleProperty('sanctuaryPbr134ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryPbr134ClientTests', pbr134Client)
if (pbr134Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.Pbr134ClientChecks') }
def ssgi132Client = providers.gradleProperty('sanctuarySsgi132ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuarySsgi132ClientTests', ssgi132Client)
if (ssgi132Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.Ssgi132ClientChecks') }
def bloom130Client = providers.gradleProperty('sanctuaryBloom130ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryBloom130ClientTests', bloom130Client)
if (bloom130Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.Bloom130ClientChecks') }
def shadows122Client = providers.gradleProperty('sanctuaryPixelShadows122ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryPixelShadows122ClientTests', shadows122Client)
if (shadows122Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks') }
@@ -0,0 +1,139 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import com.mojang.blaze3d.platform.NativeImage;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.client.Screenshot;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.block.Blocks;
/** Actual source texels, native GPU masks, occlusion and user controls in an isolated world. */
public final class Bloom130ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){new PixelShadows122ClientChecks(true).runTest(c);}
static final List<String> ORES=ores();
private static List<String> ores(){var result=new ArrayList<String>();for(String name:List.of("iron","copper","redstone","lapis","diamond","emerald","ruby","sapphire","gold")){
String ns=name.equals("ruby")||name.equals("sapphire")?"sanctuary:":"minecraft:";result.add(ns+name+"_ore");result.add(ns+"deepslate_"+name+"_ore");}
result.add("minecraft:nether_quartz_ore");result.add("minecraft:nether_gold_ore");return List.copyOf(result);}
static void run(ClientGameTestContext c,TestServerContext server){
c.runOnClient(m->{preferences();analyseTextures();VanillaLight.setSaturation(100);VanillaLight.setPixelShadows(false);VanillaLight.setEdgeHighlights(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);});
server.runOnServer(s->{command(s,"time set 18000");
for(int x=4;x<=20;x++)for(int z=4;z<=20;z++){
s.overworld().setBlockAndUpdate(new BlockPos(x,64,z),Blocks.CONCRETE.black().defaultBlockState());
for(int y=65;y<=70;y++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,z),Blocks.AIR.defaultBlockState());
}
for(int i=0;i<ORES.size();i++){var state=BuiltInRegistries.BLOCK.getValue(Identifier.parse(ORES.get(i))).defaultBlockState();check(state.getLightEmission()==0,"Ores do not acquire server-side light: "+ORES.get(i));s.overworld().setBlockAndUpdate(orePos(i),state);}
s.overworld().setBlockAndUpdate(new BlockPos(14,65,9),Blocks.REDSTONE_LAMP.defaultBlockState());
});
camera(c,server,10.5,71,9.5,60);c.waitTicks(40);c.waitFor(m->m.levelRenderer.hasRenderedAllSections(),1200);
var off=capture(c,"bloom-ores-off");
c.runOnClient(m->{VanillaLight.setOreEmission(false);VanillaLight.setBloom(true);});frames(c);
check(luminous(emission(c))==0,"White/grey rock, ordinary surfaces and unlit lamps do not seed bloom");
unchanged(off,capture(c,"bloom-ores-disabled"),"Disabling ore emission leaves the original scene intact");
c.runOnClient(m->{VanillaLight.setOreEmission(true);VanillaLight.setOreIntensity(100);});frames(c);
var source=emission(c);int orePixels=luminous(source);check(orePixels>100,"Actual exposed ore inclusions enter the GPU mask: "+orePixels);
var on=capture(c,"bloom-ores-on");double oreGain=gain(off,on,null);check(oreGain>1000,"Ores visibly glow in the native night scene: "+oreGain);
c.runOnClient(m->VanillaLight.setBloom(false));frames(c);
var coreOnly=capture(c,"emissive-bloom-off");
check(gain(off,coreOnly,null)>1000,"Emissive cores survive bloom OFF");
check(gain(off,coreOnly,source)<1,"No halo outside source texels with bloom OFF");
c.runOnClient(m->check(!Bloom.haloAllocated(),"Bloom OFF frees blur targets and uniforms"));
c.runOnClient(m->{VanillaLight.setBloom(true);VanillaLight.setBloomIntensity(0);});frames(c);
unchanged(coreOnly,capture(c,"emissive-bloom-zero"),"Zero bloom matches OFF while retaining emissive cores");
c.runOnClient(m->check(!Bloom.haloAllocated(),"Zero bloom frees blur resources"));
c.runOnClient(m->VanillaLight.setOreEmission(false));c.waitTicks(8);
c.runOnClient(m->check(!Bloom.allocated()&&!Bloom.applied(),"Zero halo and disabled cores release everything"));
unchanged(off,capture(c,null),"Disabling both restores the original scene");
c.runOnClient(m->{VanillaLight.setOreEmission(true);VanillaLight.setBloomIntensity(35);});frames(c);
unchanged(on,capture(c,null),"Re-enabling bloom restores its original image");
c.runOnClient(m->VanillaLight.setBloomIntensity(10));frames(c);double low=gain(off,capture(c,null),source);
c.runOnClient(m->VanillaLight.setBloomIntensity(90));frames(c);double high=gain(off,capture(c,"bloom-strength-90"),source);
check(high>Math.max(30,low*2),"Bloom strength changes the halo outside source pixels: low="+low+" high="+high);
c.runOnClient(m->VanillaLight.setBloomRadius(0));frames(c);var tight=capture(c,"bloom-spread-0");
c.runOnClient(m->VanillaLight.setBloomRadius(100));frames(c);var wide=capture(c,"bloom-spread-100");
check(difference(tight,wide)>100,"Spread changes the real GPU halo");
c.runOnClient(m->VanillaLight.setOreIntensity(0));frames(c);check(luminous(emission(c))==0,"Zero ore strength suppresses both cores and halos");
unchanged(off,capture(c,null),"Zero ore strength restores the source image");
c.runOnClient(m->{VanillaLight.setOreIntensity(100);VanillaLight.setBloomIntensity(35);VanillaLight.setBloomRadius(50);});frames(c);
server.runOnServer(s->{for(int i=0;i<ORES.size();i++)s.overworld().setBlockAndUpdate(orePos(i).above(),Blocks.STONE.defaultBlockState());});c.waitTicks(25);frames(c);
check(luminous(emission(c))==0,"Covering veins with stone removes all emission: no through-wall ore glow");
capture(c,"bloom-covered-ores");
server.runOnServer(s->{for(int i=0;i<ORES.size();i++)s.overworld().setBlockAndUpdate(orePos(i).above(),Blocks.AIR.defaultBlockState());});c.waitTicks(25);frames(c);
check(luminous(emission(c))>100,"Removing covers reveals the genuine visible inclusion masks again");
var reload=c.computeOnClient(net.minecraft.client.Minecraft::reloadResourcePacks);c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();frames(c);
check(luminous(emission(c))>100,"Resource reload rebuilds emitter meshes and masks");
c.runOnClient(m->VanillaLight.setOreEmission(false));
server.runOnServer(s->s.overworld().setBlockAndUpdate(new BlockPos(14,65,9),Blocks.TORCH.defaultBlockState()));c.waitTicks(25);frames(c);
int torch=luminous(emission(c));check(torch>3,"A native torch seeds bloom: "+torch);capture(c,"bloom-torch");
server.runOnServer(s->s.overworld().setBlockAndUpdate(new BlockPos(14,65,9),Blocks.GLOWSTONE.defaultBlockState()));c.waitTicks(25);frames(c);
check(luminous(emission(c))>torch,"A full luminous block contributes its real textured surface");capture(c,"bloom-glowstone");
server.runOnServer(s->s.overworld().setBlockAndUpdate(new BlockPos(14,65,9),Blocks.STONE.defaultBlockState()));c.waitTicks(25);frames(c);
check(luminous(emission(c))==0,"Replacing the light source removes its cached emission");
server.runOnServer(s->{s.overworld().setBlockAndUpdate(new BlockPos(14,65,9),Blocks.REDSTONE_LAMP.defaultBlockState());s.overworld().setBlockAndUpdate(new BlockPos(14,64,9),Blocks.REDSTONE_BLOCK.defaultBlockState());});
c.waitTicks(25);frames(c);check(luminous(emission(c))>10,"Powered lamp emits through its on-state texture");capture(c,"bloom-lamp-on");
server.runOnServer(s->s.overworld().setBlockAndUpdate(new BlockPos(14,64,9),Blocks.CONCRETE.black().defaultBlockState()));
c.waitTicks(25);frames(c);check(luminous(emission(c))==0,"Unpowered lamp loses its glow after its native state update");
server.runOnServer(s->s.overworld().setBlockAndUpdate(new BlockPos(14,65,9),Blocks.LAVA.defaultBlockState()));
c.waitTicks(25);frames(c);check(luminous(emission(c))>10,"Native visible lava seeds bloom");capture(c,"bloom-lava");
server.runOnServer(s->command(s,"time set 3000"));camera(c,server,10.5,90,9.5,70);
var direction=c.computeOnClient(m->PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle));
look(c,server,(float)Math.toDegrees(Math.atan2(-direction.x,direction.z)),-(float)Math.toDegrees(Math.asin(direction.y)));
c.runOnClient(m->VanillaLight.setBloom(false));c.waitTicks(8);var sunOff=capture(c,"bloom-sun-off");
c.runOnClient(m->VanillaLight.setBloom(true));frames(c);var sunMask=emission(c);
check(luminous(sunMask)>40,"The actual sun disc seeds bloom");
double sunlight=gain(sunOff,capture(c,"bloom-sun-on"),sunMask);check(sunlight>100,"Sun halo extends beyond its source disc: "+sunlight);
server.runOnServer(s->{var eye=s.getPlayerList().getPlayers().getFirst().getEyePosition();var center=BlockPos.containing(eye.x+direction.x*8,eye.y+direction.y*8,eye.z+direction.z*8);
for(int x=-4;x<=4;x++)for(int y=-4;y<=4;y++)for(int z=-4;z<=4;z++)s.overworld().setBlockAndUpdate(center.offset(x,y,z),Blocks.STONE.defaultBlockState());});
c.waitTicks(30);frames(c);check(luminous(emission(c))==0,"Solid terrain in front of the sun suppresses its bloom source");capture(c,"bloom-sun-occluded");
c.runOnClient(m->VanillaLight.setBloom(false));c.waitTicks(6);var coveredOff=capture(c,null);
c.runOnClient(m->{VanillaLight.setBloom(true);VanillaLight.setBloomIntensity(0);});c.waitTicks(6);c.runOnClient(m->check(!Bloom.applied()&&!Bloom.allocated(),"Zero bloom releases GPU resources"));
unchanged(coveredOff,capture(c,null),"Zero bloom leaves no residual glow");
c.runOnClient(m->{VanillaLight.setBloomIntensity(35);VanillaLight.setBloom(false);VanillaLight.setOreEmission(true);VanillaLight.setOreIntensity(50);});
System.out.println("EMISSIVE131_PASS independent cores with halo OFF/zero; blur resources released; defaults; masks for 20 ore textures; selective night glow; halo intensity/spread; no through-wall glow; live edits; torch/glowstone/lamp/lava/sun; resource reload; defaults/migration; orePixels="+orePixels+" oreGain="+oreGain+" haloLow="+low+" haloHigh="+high+" sunHalo="+sunlight);
}
private static BlockPos orePos(int i){return new BlockPos(8+i%5,65,8+i/5);}
private static void preferences(){try{
var path=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");Files.deleteIfExists(path);forgetPreferences();
check(VanillaLight.pixelShadows()&&VanillaLight.shadowDistance()==128&&VanillaLight.shadowPixels()==16&&VanillaLight.edgeDistance()==32,"Fresh requested defaults: shadows ON, 128; highlights 32; shared precision 16");
check(VanillaLight.bloom()&&VanillaLight.oreEmission()&&VanillaLight.edgeHighlights()&&VanillaLight.bloomIntensity()==35&&VanillaLight.bloomRadius()==50,"Bloom, ore emission and edge highlights default to ON");
Files.writeString(path,"{\"bloom\":false,\"oreEmission\":false,\"edgeHighlights\":false,\"pixelShadows\":false,\"shadowDistance\":64,\"shadowPixels\":8,\"edgeDistance\":16}");forgetPreferences();
check(!VanillaLight.bloom()&&!VanillaLight.oreEmission()&&!VanillaLight.edgeHighlights()&&!VanillaLight.pixelShadows()&&VanillaLight.shadowDistance()==64&&VanillaLight.shadowPixels()==8&&VanillaLight.edgeDistance()==16,"Existing user choices are retained");
for(int n:new int[]{-10,0,35,100,400}){VanillaLight.setBloomIntensity(n);VanillaLight.setBloomRadius(n);VanillaLight.setOreIntensity(n);forgetPreferences();check(VanillaLight.bloomIntensity()==Math.clamp(n,0,100)&&VanillaLight.bloomRadius()==Math.clamp(n,0,100)&&VanillaLight.oreIntensity()==Math.clamp(n,0,100),"Persisted bloom controls are bounded");}
VanillaLight.setBloomIntensity(35);VanillaLight.setBloomRadius(50);VanillaLight.setOreIntensity(50);VanillaLight.setShadowPixels(16);
}catch(java.io.IOException e){throw new AssertionError(e);}}
private static void analyseTextures(){
var report=new StringBuilder("texture,selected,total,neutral_rock_selected\n");
try(var materials=new BloomMaterials();var sheet=new NativeImage(5*16*6,4*32*6,false)){
sheet.fillRect(0,0,sheet.getWidth(),sheet.getHeight(),0xFF16191E);
for(int index=0;index<ORES.size();index++){
var block=Identifier.parse(ORES.get(index));var id=block.withPath("block/"+block.getPath());
try(var image=BloomMaterials.read(id)){
int w=image.getWidth(),h=image.getHeight();int[] pixels=new int[w*h];for(int y=0;y<h;y++)for(int x=0;x<w;x++)pixels[y*w+x]=image.getPixel(x,y);
var mask=materials.mask(id,pixels,w,h,true,false);int count=0,grey=0;
for(int i=0;i<mask.length;i++){if(mask[i]>0){count++;int c=pixels[i],r=c>>16&255,g=c>>8&255,b=c&255;if(r==g&&g==b&&r<215)grey++;}}
check(count>=4&&count<w*h*.7,"Texture has a selective inclusion mask: "+id+" "+count+"/"+mask.length);
if(!id.getPath().contains("quartz"))check(grey==0,"Neutral host rock never emits: "+id);
report.append(id).append(',').append(count).append(',').append(mask.length).append(',').append(grey).append('\n');
for(int y=0;y<32*6;y++)for(int x=0;x<16*6;x++){
int source=(y/6%16)*w/16*w+(x/6)*w/16;int color=pixels[source];if(y>=16*6&&mask[source]==0)color=0xFF16191E;
sheet.setPixel((index%5)*16*6+x,(index/5)*32*6+y,color);
}
}
}
var root=FabricLoader.getInstance().getGameDir();sheet.writeToFile(root.resolve("bloom130-ore-masks.png"));Files.writeString(root.resolve("bloom130-ore-masks.csv"),report.toString());
System.out.println("BLOOM130_MASKS\n"+report);
}catch(java.io.IOException e){throw new AssertionError(e);}
}
private static void frames(ClientGameTestContext c){int before=c.computeOnClient(m->Bloom.renderedFrames());c.waitFor(m->Bloom.applied()&&Bloom.ready()&&Bloom.renderedFrames()>before+10,1200);c.waitTicks(8);}
private static Frame emission(ClientGameTestContext c){var result=new CompletableFuture<Frame>();c.runOnClient(m->Screenshot.takeScreenshot(Bloom.emissionTarget(),image->{try(image){result.complete(new Frame(image.getWidth(),image.getHeight(),image.getPixels()));}}));c.waitFor(m->result.isDone(),200);return result.join();}
private static int luminous(Frame frame){int count=0;for(int color:frame.pixels())if((color&0xFFFFFF)!=0)count++;return count;}
private static double gain(Frame off,Frame on,Frame source){double result=0;for(int i=0;i<off.pixels().length;i++)if(source==null||(source.pixels()[i]&0xFFFFFF)==0)result+=Math.max(0,brightness(on.pixels()[i])-brightness(off.pixels()[i]));return result;}
private static int difference(Frame a,Frame b){int n=0;for(int i=0;i<a.pixels().length;i++)if(channelDifference(a.pixels()[i],b.pixels()[i])>3)n++;return n;}
}
@@ -0,0 +1,220 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import java.nio.file.Files;
import java.util.ArrayList;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.Blocks;
import org.joml.Matrix4f;
import org.joml.Vector4f;
/** Native GPU checks sharing the isolated flat-world fixture and its preference backup. */
final class EdgeHighlights129ClientChecks {
static void run(ClientGameTestContext c, TestServerContext server) {
c.runOnClient(m -> preferences());
server.runOnServer(s -> {
for (int x=10;x<=14;x++) for (int z=6;z<=10;z++) {
s.overworld().setBlockAndUpdate(new BlockPos(x,65,z),
(Math.floorMod(x,8)<4 ? Blocks.STONE : Blocks.OAK_PLANKS).defaultBlockState());
}
});
camera(c,server,12.5,72,8.5,60);
c.waitTicks(35);
shadows(c,false);
edge(c,false);
var off=capture(c,"edges-flat-off");
c.waitTicks(6);
unchanged(off,capture(c,null),"Highlight OFF control is stable");
edge(c,true);
var on=capture(c,"edges-flat-on-30");
var low=measure(off,on);
check(low.brighter>100 && low.stable>low.samples*.55 && low.darker<low.samples*.005,
"Highlight brightens local strips, preserves face interiors: "+low);
grid(c,off,on);
c.runOnClient(m -> VanillaLight.setShadowPixels(8));frames(c);
var coarse=measure(off,capture(c,"edges-shared-precision-8"));
c.runOnClient(m -> VanillaLight.setShadowPixels(32));frames(c);
var fine=measure(off,capture(c,"edges-shared-precision-32"));
check(coarse.brighter()>low.brighter()*1.4 && fine.brighter()<low.brighter()*.8,
"One precision setting controls the visible highlight width: coarse="+coarse+" middle="+low+" fine="+fine);
for(int pixels:new int[]{64,128}){
c.runOnClient(m -> VanillaLight.setShadowPixels(pixels));frames(c);
var finer=measure(off,capture(c,"edges-shared-precision-"+pixels));
check(finer.meanGain()<fine.meanGain() && finer.darker()==0,"High precision refines CTM without darkening: "+pixels+" "+finer);
}
c.runOnClient(m -> VanillaLight.setShadowPixels(16));frames(c);
for (int distance : new int[]{16,32,64,128,256}) {
c.runOnClient(m -> VanillaLight.setEdgeDistance(distance));
frames(c);
check(c.computeOnClient(m -> VanillaLight.edgeDistance())==distance,"All CTM ranges are available");
unchanged(on,capture(c,null),"Near CTM contours unchanged at range "+distance);
}
c.runOnClient(m -> VanillaLight.setEdgeDistance(32));
c.runOnClient(m -> {
check(PixelShadows.shadowMap()==null,"Highlight works without allocating a cast-shadow map");
VanillaLight.setEdgeIntensity(90);
});
frames(c);
var high=measure(off,capture(c,"edges-flat-on-90"));
check(high.meanGain>low.meanGain*1.8,"Intensity changes visible brightness: low="+low+" high="+high);
c.runOnClient(m -> VanillaLight.setEdgeIntensity(0));
c.waitTicks(6);
c.runOnClient(m -> check(!EdgeHighlights.applied() && !EdgeHighlights.allocated(),"Zero intensity frees its uniform buffer"));
unchanged(off,capture(c,"edges-zero"),"Zero intensity reproduces OFF exactly");
c.runOnClient(m -> VanillaLight.setEdgeIntensity(30));
frames(c);
camera(c,server,11.99999,72,8.5,60);
var anchor=capture(c,null);
camera(c,server,12.00001,72,8.5,60);
unchanged(anchor,capture(c,"edges-anchor-crossing"),"Grid remains stable across an integer camera anchor");
camera(c,server,12.5,72,8.5,60);
unchanged(on,capture(c,null),"Returning to the camera restores the same grid");
var reload=c.computeOnClient(net.minecraft.client.Minecraft::reloadResourcePacks);
c.waitFor(m -> reload.isDone() && m.gui.overlay()==null,1200);
reload.join();
c.runOnClient(m -> {
forgetPreferences();
check(VanillaLight.edgeHighlights() && VanillaLight.edgeIntensity()==30,"Highlight preferences survive reload and disk reread");
});
frames(c);
unchanged(on,capture(c,"edges-after-reload"),"Resource reload retains the highlight image");
edge(c,false);
unchanged(off,capture(c,"edges-off-restored"),"Disabling leaves no residual highlight");
// A new hole creates a connected boundary immediately; filling it removes that boundary.
server.runOnServer(s -> s.overworld().setBlockAndUpdate(new BlockPos(12,65,8),Blocks.AIR.defaultBlockState()));
c.waitTicks(20);
var holeOff=capture(c,null);
edge(c,true);
var hole=measure(holeOff,capture(c,"edges-ctm-hole"));
check(hole.brighter>low.brighter+20,"Removing an interior block creates a new CTM contour");
server.runOnServer(s -> s.overworld().setBlockAndUpdate(new BlockPos(12,65,8),Blocks.STONE.defaultBlockState()));
c.waitTicks(20);
unchanged(on,capture(c,null),"Filling the hole reconnects the flat surface");
// The actual radius gates a visible contour, independently of shadow range.
camera(c,server,12.5,88,8.5,30);
edge(c,false);
var farOff=capture(c,null);
c.runOnClient(m -> VanillaLight.setEdgeDistance(16));
edge(c,true);
unchanged(farOff,capture(c,"edges-beyond-16"),"CTM beyond selected 16-block radius is absent");
c.runOnClient(m -> {VanillaLight.setEdgeDistance(64);VanillaLight.setEdgeIntensity(90);});
frames(c);
var far=measure(farOff,capture(c,"edges-inside-64"));
check(far.brighter>40,"Extending the CTM radius restores a distant contour: "+far);
c.runOnClient(m -> {VanillaLight.setEdgeDistance(32);VanillaLight.setEdgeIntensity(30);});
edge(c,false);
server.runOnServer(s -> {
for (int x=9;x<=13;x+=2) for(int y=65;y<=67;y++)
s.overworld().setBlockAndUpdate(new BlockPos(x,y,12),
(x==9 ? Blocks.CONCRETE.red() : x==11 ? Blocks.STONE_BRICKS : Blocks.OAK_PLANKS).defaultBlockState());
s.overworld().setBlockAndUpdate(new BlockPos(10,65,11),Blocks.STONE_SLAB.defaultBlockState());
s.overworld().setBlockAndUpdate(new BlockPos(12,65,11),Blocks.OAK_STAIRS.defaultBlockState());
});
camera(c,server,12.5,72,7.5,70);
look(c,server,15,55);
shadows(c,true);
var reliefOff=capture(c,"edges-relief-with-shadows-off");
edge(c,true);
var reliefOn=capture(c,"edges-relief-with-shadows-on");
var relief=measure(reliefOff,reliefOn);
check(relief.brighter>100 && relief.stable>relief.samples*.5 && relief.darker<relief.samples*.01,
"Highlight composes with cast shadows and coloured textured blocks: "+relief);
look(c,server,15.00002f,55.00002f);
unchanged(reliefOn,capture(c,null),"Tiny oblique camera rotation keeps highlights stable");
c.runOnClient(m -> VanillaLight.setEnabled(false));
c.waitTicks(6);
c.runOnClient(m -> check(!EdgeHighlights.applied() && !EdgeHighlights.allocated(),"Main shader OFF also releases highlight resources"));
c.runOnClient(m -> VanillaLight.setEnabled(true));
frames(c);
edge(c,false);
System.out.println("EDGE_HIGHLIGHTS129_PASS connected contours and unchanged flat joins; independent shadows; intensity; zero/OFF release; "
+"camera anchors; oblique textured scene; resource reload; legacy preference migration; low="+low+" high="+high+" relief="+relief);
}
private static void preferences() {
var path=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");
try {
// Real prior-version JSON, without either new field. Preserve its existing settings.
Files.writeString(path,"{\"enabled\":true,\"saturation\":100,\"pixelShadows\":true,\"shadowPixels\":16,\"shadowDistance\":32}");
forgetPreferences();
check(VanillaLight.edgeHighlights() && VanillaLight.edgeIntensity()==30 && VanillaLight.edgeDistance()==32,"Missing preference uses the new highlight ON default at intensity 30");
check(VanillaLight.pixelShadows() && VanillaLight.saturation()==100,"Migration retains existing shader choices");
for(int value:new int[]{-100,0,30,100,1000}) {
VanillaLight.setEdgeIntensity(value);forgetPreferences();
check(VanillaLight.edgeIntensity()==Math.clamp(value,0,100),"Saved intensity is bounded");
}
for(int[] pair:new int[][]{{-1,16},{24,32},{48,64},{96,128},{192,256},{9999,256}}) {
VanillaLight.setEdgeDistance(pair[0]);forgetPreferences();
check(VanillaLight.edgeDistance()==pair[1],"Saved CTM distance is bounded and normalized");
}
VanillaLight.setEdgeDistance(32);
VanillaLight.setEdgeIntensity(30);
VanillaLight.setEdgeHighlights(true);forgetPreferences();
check(VanillaLight.edgeHighlights(),"Toggle persists independently");
VanillaLight.setEdgeHighlights(false);
VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);
} catch(java.io.IOException e) { throw new AssertionError(e); }
}
private static void edge(ClientGameTestContext c,boolean enabled) {
c.runOnClient(m -> VanillaLight.setEdgeHighlights(enabled));
if(enabled)frames(c);
else {
c.waitTicks(6);
c.runOnClient(m -> check(!EdgeHighlights.applied() && !EdgeHighlights.allocated(),"OFF frees resources"));
}
}
private static void frames(ClientGameTestContext c) {
int before=c.computeOnClient(m -> EdgeHighlights.renderedFrames());
c.waitFor(m -> EdgeHighlights.applied() && EdgeHighlights.renderedFrames()>before+8,200);
c.waitTicks(6);
}
private record Difference(int samples,int brighter,int stable,int darker,double meanGain) { }
private static Difference measure(Frame off,Frame on) {
int samples=0,brighter=0,stable=0,darker=0;double gain=0;
for(int y=off.height()/10;y<off.height()*9/10;y+=2) for(int x=off.width()/10;x<off.width()*9/10;x+=2) {
int i=y*off.width()+x,a=off.pixels()[i],b=on.pixels()[i];
int delta=brightness(b)-brightness(a);samples++;gain+=delta;
if(delta>=4)brighter++;
if(delta<=-4)darker++;
if(channelDifference(a,b)<=2)stable++;
}
var result=new Difference(samples,brighter,stable,darker,gain/samples);
System.out.println("EDGE_HIGHLIGHTS129_PIXELS "+result);
return result;
}
private static void grid(ClientGameTestContext c,Frame off,Frame on) {
var samples=c.computeOnClient(m -> {
var camera=m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState;
var vp=new Matrix4f(camera.projectionMatrix).mul(camera.viewRotationMatrix);
var result=new ArrayList<int[]>();
for(int x=11;x<=14;x++) for(int z=6;z<=10;z++) for(int edge=0;edge<2;edge++) {
var p=vp.transform(new Vector4f((float)(x+(edge==0?.5:1.0/32)-camera.pos.x),
(float)(66-camera.pos.y),(float)(z+.5-camera.pos.z),1));
int px=(int)((p.x/p.w*.5+.5)*off.width());
int py=(int)((.5-p.y/p.w*.5)*off.height());
if(px>=10 && px<off.width()-10 && py>=10 && py<off.height()-10)result.add(new int[]{py*off.width()+px,edge});
}
return result;
});
int centers=0,unchanged=0,edges=0,bright=0;
for(var sample:samples) {
int a=off.pixels()[sample[0]],b=on.pixels()[sample[0]];
if(sample[1]==0){centers++;if(channelDifference(a,b)<=2)unchanged++;}
else {edges++;if(brightness(b)-brightness(a)>=4)bright++;}
}
check(centers>=15 && unchanged>centers*.95 && edges>=15 && bright==0,
"Connected block centers and coplanar joins remain untouched: centers="+unchanged+"/"+centers+" edges="+bright+"/"+edges);
System.out.println("EDGE_HIGHLIGHTS129_GRID centers="+unchanged+"/"+centers+" edges="+bright+"/"+edges);
}
}
@@ -0,0 +1,73 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.Blocks;
import java.nio.file.Files;
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();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);});
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());
}});
camera(c,server,10.5,69,5.5,65);look(c,server,0,40);settle(c);
var off=capture(c,"pbr-off");enable(c,true);var on=capture(c,"pbr-50");
var delta=difference(off,on);System.out.println("PBR134_DIFF "+delta);check(delta.changed>500&&delta.brighter>100&&delta.darker>100,"Texture relief has lit and shaded pixels: "+delta);
int analyses=c.computeOnClient(m->ProceduralPbr.analyses());c.waitTicks(20);
c.runOnClient(m->check(analyses==ProceduralPbr.analyses()&&ProceduralPbr.materialCount()>0,"Source textures are cached, not regenerated every frame"));
unchanged(on,capture(c,null),"Static PBR has no animated noise");
look(c,server,.00002f,40.00002f);frames(c);unchanged(on,capture(c,null),"PBR tiny camera motion");look(c,server,0,40);frames(c);
c.runOnClient(m->VanillaLight.setPbrIntensity(20));frames(c);var low=difference(off,capture(c,"pbr-20"));
check(delta.total>low.total*1.3,"PBR strength affects actual GPU output");
c.runOnClient(m->VanillaLight.setPbrIntensity(100));frames(c);var high=difference(off,capture(c,"pbr-100"));check(high.total>delta.total*1.2,"Strong PBR visibly increases relief");
c.runOnClient(m->VanillaLight.setPbrIntensity(0));c.waitTicks(8);unchanged(off,capture(c,null),"PBR zero restores native image");
c.runOnClient(m->check(!ProceduralPbr.allocated()&&ProceduralPbr.materialCount()==0,"Zero releases targets and material cache"));
c.runOnClient(m->VanillaLight.setPbrIntensity(50));frames(c);
server.runOnServer(s->command(s,"time set 11000"));settle(c);enable(c,false);var eveningOff=capture(c,null);enable(c,true);var eveningOn=capture(c,"pbr-evening");
int directions=0;for(int i=0;i<on.pixels().length;i++){int am=(on.pixels()[i]>>16&255)-(off.pixels()[i]>>16&255),pm=(eveningOn.pixels()[i]>>16&255)-(eveningOff.pixels()[i]>>16&255);if(Math.abs(am-pm)>5)directions++;}
check(directions>500,"Relief follows the moving sun: "+directions);
var reload=c.computeOnClient(net.minecraft.client.Minecraft::reloadResourcePacks);c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();frames(c);unchanged(eveningOn,capture(c,"pbr-reloaded"),"Resource reload rebuilds consistent cached materials");
// Live geometry and light invalidation: an enclosing roof removes exposure to the sky.
server.runOnServer(s->{for(int x=-8;x<=28;x++)for(int z=-8;z<=28;z++)s.overworld().setBlockAndUpdate(new BlockPos(x,73,z),Blocks.STONE.defaultBlockState());});
settle(c);frames(c);var roofOn=capture(c,"pbr-roof-on");enable(c,false);var roofOff=capture(c,"pbr-roof-off");
unchanged(roofOff,roofOn,"Roof prevents outdoor highlights on enclosed floor");enable(c,true);
server.runOnServer(s->{for(int x=-8;x<=28;x++)for(int z=-8;z<=28;z++)s.overworld().setBlockAndUpdate(new BlockPos(x,73,z),Blocks.AIR.defaultBlockState());});
settle(c);frames(c);unchanged(eveningOn,capture(c,"pbr-roof-removed"),"Removing roof restores material light without stale meshes");
int oldWidth=c.computeOnClient(m->m.getWindow().getScreenWidth()),oldHeight=c.computeOnClient(m->m.getWindow().getScreenHeight());
c.runOnClient(m->m.getWindow().setWindowed(961,541));c.waitFor(m->m.gameRenderer.mainRenderTarget().width!=on.width(),200);frames(c);capture(c,"pbr-resized");
c.runOnClient(m->m.getWindow().setWindowed(oldWidth,oldHeight));c.waitFor(m->m.gameRenderer.mainRenderTarget().width==on.width(),200);frames(c);
c.runOnClient(m->{VanillaLight.setPixelShadows(true);VanillaLight.setSsgi(true);VanillaLight.setBloom(true);VanillaLight.setOreEmission(true);VanillaLight.setEdgeHighlights(true);});
c.waitFor(m->PixelShadows.applied()&&PixelShadows.ready()&&ScreenSpaceGI.applied()&&Bloom.applied()&&EdgeHighlights.applied()&&ProceduralPbr.applied(),1200);frames(c);capture(c,"pbr-combined");
c.runOnClient(m->VanillaLight.setEnabled(false));c.waitTicks(8);c.runOnClient(m->check(!ProceduralPbr.allocated()&&ProceduralPbr.materialCount()==0,"Global OFF releases PBR"));
c.runOnClient(m->{VanillaLight.setEnabled(true);VanillaLight.setPbr(false);VanillaLight.setSsgi(false);});
System.out.println("PBR134_PASS normals; profiles; texture cache; directional lighting; intensity; OFF/zero; tiny camera; reload; resize; combined; default="+delta+" high="+high+" directional="+directions);
}
private static void materials(){
int[] flat=new int[16];Arrays.fill(flat,0xFF808080);var profile=PbrMaterials.profile("bricks");
for(int p:PbrMaterials.generate(flat,4,4,profile))check((p>>8&0xFFFF)==0x8080,"Flat source has neutral normal");
flat[5]=0xFFFFFFFF;var slope=PbrMaterials.generate(flat,4,4,profile);check((slope[4]>>16&255)<128&&(slope[6]>>16&255)>128,"Height gradients produce opposite normal slopes");
check(PbrMaterials.profile("iron_block").metal()>.8&&PbrMaterials.profile("iron_ore").metal()==0,"Metal block profile does not turn ore host rock into metal");
check(PbrMaterials.profile("oak_planks").roughness()>PbrMaterials.profile("polished_andesite").roughness(),"Wood remains rougher than polished stone");
}
private static void preferences(){try{
var p=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");Files.writeString(p,"{\"ssgi\":true,\"ssgiIntensity\":35,\"bloom\":false}");forgetPreferences();
check(!VanillaLight.pbr()&&VanillaLight.pbrIntensity()==50&&VanillaLight.ssgi()&&!VanillaLight.bloom(),"PBR optional default preserves 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);}}
private record Delta(int changed,int brighter,int darker,long total){}
private static Delta difference(Frame off,Frame on){int changed=0,bright=0,dark=0;long total=0;for(int i=0;i<off.pixels().length;i++){
int a=off.pixels()[i],b=on.pixels()[i],delta=(b>>16&255)-(a>>16&255);total+=Math.abs(delta);if(Math.abs(delta)>3)changed++;if(delta>3)bright++;if(delta< -3)dark++;
}return new Delta(changed,bright,dark,total);}
private static void settle(ClientGameTestContext c){c.waitTicks(35);c.waitFor(m->m.levelRenderer.hasRenderedAllSections(),1200);c.waitTicks(10);}
private static void enable(ClientGameTestContext c,boolean on){c.runOnClient(m->VanillaLight.setPbr(on));if(on)frames(c);else c.waitTicks(8);}
private static void frames(ClientGameTestContext c){int start=c.computeOnClient(m->ProceduralPbr.renderedFrames());c.waitFor(m->ProceduralPbr.applied()&&ProceduralPbr.ready()&&ProceduralPbr.renderedFrames()>start+10,1200);c.waitTicks(8);}
}
@@ -36,6 +36,11 @@ import net.minecraft.world.phys.Vec3;
/** Real terrain/depth-map checks on a new flat seed 122 world; GPU readback is test-only. */
public final class PixelShadows122ClientChecks implements FabricClientGameTest {
private final boolean bloomOnly,ssgiOnly,pbrOnly;
public PixelShadows122ClientChecks(){this(false);}
PixelShadows122ClientChecks(boolean bloomOnly){this(bloomOnly,false);}
PixelShadows122ClientChecks(boolean bloomOnly,boolean ssgiOnly){this(bloomOnly,ssgiOnly,false);}
PixelShadows122ClientChecks(boolean bloomOnly,boolean ssgiOnly,boolean pbrOnly){this.bloomOnly=bloomOnly;this.ssgiOnly=ssgiOnly;this.pbrOnly=pbrOnly;}
private static final int FLOOR = 64;
private static final AABB PILLAR = new AABB(0, FLOOR + 1, 0, 2, FLOOR + 9, 2);
private static final BlockPos SLAB = new BlockPos(5, FLOOR + 3, -5);
@@ -121,10 +126,18 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
c.waitFor(m -> m.level.getBlockState(new BlockPos(0, FLOOR + 8, 0)).is(Blocks.CONCRETE.red())
&& m.level.getBlockState(SLAB).is(Blocks.RED_SANDSTONE_SLAB)
&& m.level.getBlockState(STAIR).is(Blocks.RED_SANDSTONE_STAIRS), 200);
c.waitFor(m -> {
for(int x=-24;x<=24;x+=8) for(int z=-24;z<=24;z+=8)
if(!m.level.getBlockState(new BlockPos(x,FLOOR,z)).is(Blocks.CONCRETE.white()))return false;
return m.levelRenderer.hasRenderedAllSections();
},1200);
c.waitTicks(40);
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(pbrOnly){Pbr134ClientChecks.run(c,server);return;}
if(ssgiOnly){Ssgi132ClientChecks.run(c,server);return;}
if(bloomOnly){Bloom130ClientChecks.run(c,server);return;}
// Keep VanillaLight enabled for both frames: changing its fog/grade would invalidate this oracle.
shadows(c, false);
var off = capture(c, "day-off");
@@ -140,14 +153,14 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
check(PixelShadows.shadowMap() != null, "Daylight allocates a real shadow target");
check(PixelShadows.shadowMap().width == PixelShadows.mapSize(32, 16), "Shadow map follows selected quality");
});
for (int pixels : new int[]{8, 32, 16}) {
for (int pixels : new int[]{8, 32, 64, 128, 16}) {
c.runOnClient(m -> VanillaLight.setShadowPixels(pixels));
activeFrames(c);
c.runOnClient(m -> check(PixelShadows.shadowMap().width == PixelShadows.mapSize(32, pixels),
"Live pixel density reallocates the correct depth map"));
capture(c, "precision-" + pixels);
}
for (int distance : new int[]{16, 64, 32}) {
for (int distance : new int[]{16, 64, 128, 256, 32}) {
c.runOnClient(m -> VanillaLight.setShadowDistance(distance));
activeFrames(c);
c.runOnClient(m -> check(PixelShadows.shadowMap().width == PixelShadows.mapSize(distance, 16),
@@ -229,12 +242,17 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
});
activeFrames(c);
unchanged(beforeReload, capture(c, "after-reload"), "Resource reload preserves the rendered ground shadow");
RealtimeShadows129ClientChecks.run(c, server);
EdgeHighlights129ClientChecks.run(c, server);
Bloom130ClientChecks.run(c, server);
Ssgi132ClientChecks.run(c, server);
Pbr134ClientChecks.run(c, server);
c.getInput().pressKey(InputConstants.KEY_F1);
for (String locale : new String[]{"fr_fr", "en_us"}) {
c.runOnClient(m -> {
m.getLanguageManager().setSelected(locale);
m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
for (String key : new String[]{"pixel_shadows", "shadow_pixels", "shadow_distance"}) {
for (String key : new String[]{"pixel_shadows", "shadow_pixels", "shadow_distance", "edge_highlights", "edge_intensity", "edge_distance", "precision", "ssgi", "ssgi_intensity", "bloom", "bloom_radius", "ore_emission"}) {
String fullKey = "sanctuary.shaders." + key;
check(!Component.translatable(fullKey).getString().equals(fullKey), "Translated " + locale + " " + key);
}
@@ -246,12 +264,12 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
});
});
c.waitTicks(3);
c.takeScreenshot("sanctuary-beta128-shadows-options-" + locale);
c.takeScreenshot("sanctuary-beta134-shader-options-" + locale);
}
c.runOnClient(m -> m.gui.setScreen(null));
}
System.out.println("PIXEL_SHADOWS122_CLIENT_PASS: bounded saved preferences; native GPU ON/OFF ground; "
+ "offscreen caster; live removal/replacement; subtle moon; noon attenuation; foliage density; camera stability; resource reload; FR/EN screenshots");
+ "offscreen caster; live removal/replacement; subtle moon; full-strength noon; foliage density; camera stability; resource reload; FR/EN screenshots");
} finally {
onboarding.set(false);
c.runOnClient(m -> {
@@ -287,8 +305,8 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
var noonOn = capture(c, "noon-on");
var noonStats = opacity(noonOff, noonOn);
crispEdge(noonOff, noonOn);
check(noonStats.dark > 100 && noonStats.maximum > .04 && noonStats.maximum < .14,
"Noon shadow is present but attenuated: " + noonStats);
check(noonStats.dark > 100 && noonStats.maximum > .34 && noonStats.maximum < .42,
"Noon shadow keeps its full daytime strength: " + noonStats);
camera(c, server, 1.00001, FLOOR + 5, 1, 70);
unchanged(noonOn, capture(c, "noon-camera-subpixel"), "Noon tiny motion across former integer anchor");
camera(c, server, .99999, FLOOR + 5, 1, 70);
@@ -379,7 +397,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
if (!whiteGround(off.pixels[i])) continue;
samples++;
double ratio=(brightness(off.pixels[i])-brightness(on.pixels[i]))/(double)brightness(off.pixels[i]);
if (ratio > .015 && ratio < .070) intermediate++;
if (ratio > .015 && ratio < .32) intermediate++;
}
check(samples > 1000 && intermediate < samples / 200,
"Opaque noon edge stays crisp, no foliage smoothing: " + intermediate + "/" + samples);
@@ -411,14 +429,18 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
}
private static void checkPreferences() {
VanillaLight.setSsgi(false);
VanillaLight.setBloom(false);
VanillaLight.setOreEmission(false);
VanillaLight.setEnabled(true);
VanillaLight.setEdgeHighlights(false);
VanillaLight.setSaturation(100);
for (int[] pair : new int[][]{{Integer.MIN_VALUE, 8}, {11, 8}, {12, 16}, {16, 16}, {23, 16}, {24, 32}, {Integer.MAX_VALUE, 32}}) {
for (int[] pair : new int[][]{{Integer.MIN_VALUE, 8}, {11, 8}, {12, 16}, {16, 16}, {23, 16}, {24, 32}, {47,32}, {48,64}, {64,64}, {95,64}, {96,128}, {128,128}, {Integer.MAX_VALUE,128}}) {
VanillaLight.setShadowPixels(pair[0]);
forgetPreferences();
check(VanillaLight.shadowPixels() == pair[1], "Persisted pixel density rounds/clamps " + pair[0]);
}
for (int[] pair : new int[][]{{Integer.MIN_VALUE, 16}, {23, 16}, {24, 32}, {32, 32}, {47, 32}, {48, 64}, {Integer.MAX_VALUE, 64}}) {
for (int[] pair : new int[][]{{Integer.MIN_VALUE, 16}, {23, 16}, {24, 32}, {32, 32}, {47, 32}, {48, 64}, {95,64}, {96,128}, {191,128}, {192,256}, {256,256}, {Integer.MAX_VALUE, 256}}) {
VanillaLight.setShadowDistance(pair[0]);
forgetPreferences();
check(VanillaLight.shadowDistance() == pair[1], "Persisted shadow distance rounds/clamps " + pair[0]);
@@ -432,7 +454,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
VanillaLight.setShadowDistance(32);
}
private static void forgetPreferences() {
static void forgetPreferences() {
try {
var field = VanillaLight.class.getDeclaredField("preferences");
field.setAccessible(true);
@@ -442,7 +464,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
}
}
private static void camera(ClientGameTestContext c, TestServerContext server, double x, double y, double z, int fov) {
static void camera(ClientGameTestContext c, TestServerContext server, double x, double y, double z, int fov) {
server.runOnServer(s -> {
var p = s.getPlayerList().getPlayers().getFirst();
command(s, "tp @a " + x + " " + y + " " + z + " 0 90");
@@ -461,10 +483,10 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
c.waitTicks(15);
}
private static void look(ClientGameTestContext c, TestServerContext server, float yaw, float pitch) {
static void look(ClientGameTestContext c, TestServerContext server, float yaw, float pitch) {
server.runOnServer(s -> {
var p = s.getPlayerList().getPlayers().getFirst();
command(s, "tp @a " + p.getX() + " " + p.getY() + " " + p.getZ() + " " + yaw + " " + pitch);
command(s, "tp @a " + p.getX() + " " + p.getY() + " " + p.getZ() + String.format(java.util.Locale.ROOT," %.8f %.8f",yaw,pitch));
});
c.waitFor(m -> Math.abs(m.player.getXRot() - pitch) < .00001
&& Math.abs(m.player.getYRot() - yaw) < .00001, 200);
@@ -478,7 +500,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
}
}
private static void command(MinecraftServer s, String command) {
static void command(MinecraftServer s, String command) {
try {
// Dispatcher exceptions fail the fixture instead of leaving an ignored command error in chat.
s.getCommands().getDispatcher().execute(command, s.createCommandSourceStack());
@@ -487,7 +509,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
}
}
private static void shadows(ClientGameTestContext c, boolean enabled) {
static void shadows(ClientGameTestContext c, boolean enabled) {
c.runOnClient(m -> VanillaLight.setPixelShadows(enabled));
if (enabled) activeFrames(c);
else {
@@ -501,15 +523,15 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
}
}
private static void activeFrames(ClientGameTestContext c) {
static void activeFrames(ClientGameTestContext c) {
int before = c.computeOnClient(m -> PixelShadows.renderedFrames());
c.waitFor(m -> PixelShadows.applied() && PixelShadows.ready() && PixelShadows.renderedFrames() > before + 12, 200);
// Allow the bounded terrain cache and native light/chunk uploads to settle before reading pixels.
c.waitTicks(30);
}
private static Frame capture(ClientGameTestContext c, String name) {
if (name != null) c.takeScreenshot("sanctuary-beta128-shadows-" + name);
static Frame capture(ClientGameTestContext c, String name) {
if (name != null) c.takeScreenshot("sanctuary-beta134-shader-" + name);
var result = new CompletableFuture<Frame>();
c.runOnClient(m -> Screenshot.takeScreenshot(m.gameRenderer.mainRenderTarget(), image -> {
try (image) {
@@ -522,7 +544,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
return result.join();
}
private record Frame(int width, int height, int[] pixels) { }
record Frame(int width, int height, int[] pixels) { }
private record Difference(int sampled, int ground, int dark, int stable, int brighter) { }
private static Difference compare(Frame before, Frame after) {
@@ -552,7 +574,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
check(d.brighter < Math.max(12, d.ground / 100), label + ": shadows must not brighten ground: " + d);
}
private static void unchanged(Frame before, Frame after, String label) {
static void unchanged(Frame before, Frame after, String label) {
sameSize(before, after);
int sampled = 0, changed = 0;
for (int y = before.height / 10; y < before.height * 9 / 10; y += 2) {
@@ -595,11 +617,11 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
return Math.min(r, Math.min(g, b)) > 100 && Math.max(r, Math.max(g, b)) - Math.min(r, Math.min(g, b)) < 24;
}
private static int brightness(int color) {
static int brightness(int color) {
return ((color >> 16 & 255) + (color >> 8 & 255) + (color & 255)) / 3;
}
private static int channelDifference(int a, int b) {
static int channelDifference(int a, int b) {
return Math.max(Math.abs((a >> 16 & 255) - (b >> 16 & 255)),
Math.max(Math.abs((a >> 8 & 255) - (b >> 8 & 255)), Math.abs((a & 255) - (b & 255))));
}
@@ -630,5 +652,5 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
}
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); }
static void check(boolean ok, String message) { if (!ok) throw new AssertionError(message); }
}
@@ -0,0 +1,115 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import fr.koka.sanctuary.realtime.RealtimeService;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
import net.fabricmc.fabric.api.client.rendering.v1.level.LevelRenderEvents;
final class RealtimeShadows129ClientChecks {
private static void projectionContinuity() {
// Far coordinates amplify the old per-frame rounding failure. Moving the sun must not
// add a periodic translation to the shadow raster while the camera remains stationary.
var grid=new ShadowProjection();
float last=0; int jumps=0; float greatest=0;
for(int i=0;i<2000;i++) {
var matrix=grid.matrix(PixelShadows.sunDirection(.7f+i*.000001f),32,2048,100000,64,-100000,false);
if(i>0){float step=Math.abs(matrix.m31()-last);greatest=Math.max(greatest,step);if(step>1e-6f)jumps++;}
last=matrix.m31();
}
check(jumps==0,"Slow light rotation has no cyclical raster translation far from spawn: "+greatest);
// Recentring transfers the phase modulo integral texels, preserving the sampled grid.
var sun=PixelShadows.sunDirection(.71f);
var before=grid.matrix(sun,32,2048,100000,64,-100000,false);
var after=grid.matrix(sun,32,2048,100016,64,-100000,false);
var a=before.transform(new org.joml.Vector4f(4,1,2,1));
var b=after.transform(new org.joml.Vector4f(-12,1,2,1));
for(float value:new float[]{(b.x-a.x)*1024,(b.y-a.y)*1024})
check(Math.abs(value-Math.rint(value))<.001,"Camera recentering moves only whole shadow texels");
}
static void run(ClientGameTestContext c, TestServerContext server) {
projectionContinuity();
var enabled=new AtomicBoolean(false);
var trace=Collections.synchronizedList(new ArrayList<double[]>());
LevelRenderEvents.END_EXTRACTION.register(context -> {
if(enabled.get() && trace.size()<4096)trace.add(new double[]{System.nanoTime()/1e9,context.levelState().skyRenderState.sunAngle});
});
var original=new AtomicReference<Clock>();
try {
server.runOnServer(s -> {
var session=RealtimeService.session(s);
check(session!=null,"Isolated world has the actual real-time service");
try {
var field=session.getClass().getDeclaredField("clock");field.setAccessible(true);
original.set((Clock)field.get(session));
var morning=session.sample().day().noon().minusSeconds(3*3600);
field.set(session,Clock.offset(Clock.systemUTC(),Duration.between(Instant.now(),morning)));
check(RealtimeService.setMode(s,true),"Real-time mode enabled through actual server service");
} catch(ReflectiveOperationException e) {throw new AssertionError(e);}
catch(Exception e) {throw new AssertionError(e);}
});
c.waitFor(m -> PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle).y>.2,200);
c.waitTicks(40);
double shadowX=c.computeOnClient(m -> {
var sun=PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle);
return 1-7*sun.x/sun.y;
});
camera(c,server,shadowX,69,1,30);
shadows(c,false);
var off=capture(c,null);
shadows(c,true);
enabled.set(true);
var frames=new ArrayList<Frame>();
for(int i=0;i<24;i++) {c.waitTicks(6);frames.add(capture(c,i==0?"realtime-start":i==23?"realtime-end":null));}
c.runOnClient(m -> enabled.set(false));
double travel=Math.IEEEremainder(trace.getLast()[1]-trace.getFirst()[1],Math.PI*2);
int reverse=0;double largest=0;
for(int i=1;i<trace.size();i++) {
double delta=Math.IEEEremainder(trace.get(i)[1]-trace.get(i-1)[1],Math.PI*2)*Math.signum(travel);
if(delta<-.0000001){reverse++;largest=Math.max(largest,-delta);}
}
int samples=0,changed=0,rebound=0;
for(int y=off.height()/10;y<off.height()*9/10;y+=2) for(int x=off.width()/10;x<off.width()*9/10;x+=2) {
int index=y*off.width()+x,a=off.pixels()[index];
int r=a>>16&255,g=a>>8&255,b=a&255;
if(Math.min(r,Math.min(g,b))<100 || Math.max(r,Math.max(g,b))-Math.min(r,Math.min(g,b))>24)continue;
samples++;int flips=0;
boolean last=brightness(a)-brightness(frames.getFirst().pixels()[index])>=8;
for(var frame:frames) {
boolean dark=brightness(a)-brightness(frame.pixels()[index])>=8;
if(last!=dark)flips++;
last=dark;
}
if(flips>0)changed++;
if(flips>1)rebound++;
}
check(trace.size()>30 && Math.abs(travel)>.00001,"Real-time shadows actually remain dynamic");
check(rebound<Math.max(8,samples/1000),"Real-time opaque contours must not repeatedly rebound: "+rebound);
System.out.println("REALTIME129_PASS frames="+trace.size()+" rawTravel="+travel+" reversals="+reverse+" largestReverse="+largest
+" pixels="+samples+" changed="+changed+" rebound="+rebound);
try {
var out=new StringBuilder("seconds,raw_angle\n");
for(var sample:trace)out.append(sample[0]).append(',').append(sample[1]).append('\n');
java.nio.file.Files.writeString(net.fabricmc.loader.api.FabricLoader.getInstance().getGameDir().resolve("shadow129-clock.csv"),out);
}catch(java.io.IOException e){throw new AssertionError(e);}
} finally {
enabled.set(false);
server.runOnServer(s -> {
try {
RealtimeService.setMode(s,false);
if(original.get()!=null){var session=RealtimeService.session(s);var field=session.getClass().getDeclaredField("clock");field.setAccessible(true);field.set(session,original.get());}
command(s,"time set 3000");
}catch(Exception e){throw new AssertionError(e);}
});
}
}
}
@@ -0,0 +1,125 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
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.setEnabled(true);VanillaLight.setSaturation(100);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());
}});
camera(c,server,10.5,69,5.5,65);look(c,server,0,40);settle(c);
var flatOff=capture(c,"ssgi-flat-off");enable(c,true);unchanged(flatOff,capture(c,"ssgi-flat-on"),"Coplanar ground does not bounce light onto itself");
wall(server,12,Blocks.CONCRETE.red().defaultBlockState());settle(c);enable(c,false);var redOff=capture(c,"ssgi-red-off");
c.runOnClient(m->VanillaLight.setSsgiIntensity(35));
enable(c,true);var defaultRed=measure(redOff,capture(c,"ssgi-red-default-35"));
System.out.println("SSGI133_DEFAULT "+defaultRed);
check(defaultRed.visible>5000&&defaultRed.strong>500,"Default intensity produces 8/255 gain over 5000 pixels and 16/255 over 500 pixels: "+defaultRed);
c.runOnClient(m->VanillaLight.setSsgiIntensity(90));frames(c);
var redOn=capture(c,"ssgi-red-on");var red=measure(redOff,redOn);System.out.println("SSGI133_RED "+red);
check(red.changed>30&&red.red>red.blue*1.5+20,"Visible red wall tints neutral ground: "+red);
unchanged(redOn,capture(c,null),"Stationary SSGI has no animated noise or accumulating feedback");
look(c,server,0.00002f,40.00002f);frames(c);unchanged(redOn,capture(c,null),"Subpixel motion keeps actual red bounce stable");
look(c,server,5,40);frames(c);capture(c,"ssgi-camera-turned");
look(c,server,0,40);frames(c);unchanged(redOn,capture(c,null),"Returning the camera leaves no temporal color trails");
c.runOnClient(m->VanillaLight.setSsgiIntensity(20));frames(c);var low=measure(redOff,capture(c,"ssgi-strength-20"));
check(red.red>low.red*1.5,"Intensity adjusts native color bounce: "+red+" / "+low);
c.runOnClient(m->VanillaLight.setSsgiIntensity(0));c.waitTicks(6);unchanged(redOff,capture(c,null),"Zero intensity restores source image");
c.runOnClient(m->check(!ScreenSpaceGI.allocated()&&!ScreenSpaceGI.applied(),"Zero frees SSGI targets"));
c.runOnClient(m->VanillaLight.setSsgiIntensity(90));frames(c);
int oldWidth=c.computeOnClient(m->m.getWindow().getScreenWidth()),oldHeight=c.computeOnClient(m->m.getWindow().getScreenHeight());
c.runOnClient(m->m.getWindow().setWindowed(961,541));
c.waitFor(m->m.gameRenderer.mainRenderTarget().width!=redOn.width(),200);frames(c);
enable(c,false);var resizedOff=capture(c,null);enable(c,true);var resized=measure(resizedOff,capture(c,"ssgi-resized"));
check(resized.changed>30&&resized.red>resized.blue*1.5+20,"Resizing reallocates a working SSGI pass: "+resized);
c.runOnClient(m->m.getWindow().setWindowed(oldWidth,oldHeight));
c.waitFor(m->m.gameRenderer.mainRenderTarget().width==redOn.width(),200);frames(c);
wall(server,12,Blocks.CONCRETE.blue().defaultBlockState());settle(c);enable(c,false);var blueOff=capture(c,"ssgi-blue-off");enable(c,true);var blue=measure(blueOff,capture(c,"ssgi-blue-on"));
c.runOnClient(m->VanillaLight.setSsgiIntensity(35));frames(c);
var defaultBlue=measure(blueOff,capture(c,"ssgi-blue-default-35"));
check(defaultBlue.visible>5000&&defaultBlue.strong>500&&defaultBlue.blue>defaultBlue.red*1.5+20,"Default blue bounce is visible too: "+defaultBlue);
c.runOnClient(m->VanillaLight.setSsgiIntensity(90));frames(c);
System.out.println("SSGI133_BLUE "+blue+" default="+defaultBlue);check(blue.changed>30&&blue.blue>blue.red*1.5+20,"Live blue wall changes bounced hue: "+blue);
wall(server,11,Blocks.CONCRETE.white().defaultBlockState());settle(c);enable(c,false);var coveredOff=capture(c,null);enable(c,true);var covered=capture(c,"ssgi-covered-blue");
wall(server,12,Blocks.CONCRETE.red().defaultBlockState());settle(c);unchanged(covered,capture(c,"ssgi-covered-red"),"Hidden wall color cannot pass through foreground wall");
var reload=c.computeOnClient(net.minecraft.client.Minecraft::reloadResourcePacks);c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();frames(c);unchanged(covered,capture(c,"ssgi-reloaded"),"Resource reload restores SSGI");
look(c,server,0.00002f,40.00002f);frames(c);unchanged(covered,capture(c,null),"Subpixel camera movement keeps bounce stable");
look(c,server,0,40);frames(c);enable(c,false);unchanged(coveredOff,capture(c,null),"OFF leaves no residual tint");
c.runOnClient(m->check(!ScreenSpaceGI.allocated(),"OFF frees SSGI resources"));
c.runOnClient(m->{VanillaLight.setSsgiIntensity(35);VanillaLight.setPixelShadows(true);VanillaLight.setEdgeHighlights(true);VanillaLight.setBloom(true);VanillaLight.setOreEmission(true);});
enable(c,true);c.waitFor(m->PixelShadows.applied()&&PixelShadows.ready()&&EdgeHighlights.applied()&&Bloom.applied(),1200);
capture(c,"ssgi-combined-shader");
c.runOnClient(m->VanillaLight.setEnabled(false));c.waitTicks(8);
c.runOnClient(m->check(!ScreenSpaceGI.allocated(),"Main shader OFF releases SSGI too"));
c.runOnClient(m->VanillaLight.setEnabled(true));frames(c);enable(c,false);
motion(c,server);
System.out.println("SSGI133_PASS red/blue diffuse bounce; intensity; flat rejection; foreground occlusion; static stability; tiny camera motion; resize; reload; combined shader; OFF/zero; preferences; red="+red+" blue="+blue);
}
private static void motion(ClientGameTestContext c,TestServerContext server){
c.runOnClient(m->{VanillaLight.setPbr(false);VanillaLight.setSsgi(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);VanillaLight.setPixelShadows(false);VanillaLight.setEdgeHighlights(false);});
wall(server,11,Blocks.AIR.defaultBlockState());
server.runOnServer(s->{
command(s,"summon minecraft:cow 8.5 65 9.5 {NoAI:1b,Silent:1b,NoGravity:1b,Invulnerable:1b,Tags:[\"ssgi134_test\"]}");
for(int x=11;x<=13;x++)for(int z=8;z<=10;z++){
s.overworld().setBlockAndUpdate(new BlockPos(x,64,z),Blocks.GRASS_BLOCK.defaultBlockState());
s.overworld().setBlockAndUpdate(new BlockPos(x,65,z),Blocks.SHORT_GRASS.defaultBlockState());
}
});settle(c);
for(int intensity:new int[]{35,100}){
c.runOnClient(m->VanillaLight.setSsgiIntensity(intensity));
Frame start=null;double worstGrass=0;
for(int step=0;step<=12;step++){
double x=4.5+step;camera(c,server,x,66.5,5.5,65);
look(c,server,(float)Math.toDegrees(Math.atan2(x-10.5,6.5)),10);
enable(c,false);var base=capture(c,"ssgi-motion-"+intensity+"-"+step+"-off");
enable(c,true);var frame=capture(c,"ssgi-motion-"+intensity+"-"+step+"-on");
double grass=grassGain(base,frame);worstGrass=Math.max(worstGrass,grass);
check(grass<(intensity==35?12:30),"Lateral view avoids excessive grass brightening at "+intensity+" / "+step+": "+grass);
if(step==0)start=frame;
}
System.out.println("SSGI134_GRASS intensity="+intensity+" maxMeanGreenGain="+worstGrass);
camera(c,server,4.5,66.5,5.5,65);look(c,server,(float)Math.toDegrees(Math.atan2(-6,6.5)),10);frames(c);
unchanged(start,capture(c,null),"Twelve-block lateral movement returns without stale SSGI history at "+intensity);
}
enable(c,false);c.runOnClient(m->VanillaLight.setSsgiIntensity(35));
server.runOnServer(s->command(s,"kill @e[tag=ssgi134_test]"));
System.out.println("SSGI134_MOTION_PASS 12-block lateral sweep, grass/cow/blocks, OFF/ON at35 and100, return without history");
}
private static double grassGain(Frame off,Frame on){
int count=0;long gain=0;
for(int i=0;i<off.pixels().length;i++){
int a=off.pixels()[i],r=a>>16&255,g=a>>8&255,b=a&255;
if(g>r*1.15&&g>b*1.3&&g>45){count++;gain+=Math.max(0,(on.pixels()[i]>>8&255)-g);}
}
check(count>500,"Motion fixture includes visible vegetation");return gain/(double)count;
}
private static void wall(TestServerContext server,int z,BlockState state){server.runOnServer(s->{for(int x=6;x<=15;x++)for(int y=65;y<=(z==11?72:69);y++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,z),state);});}
private static void settle(ClientGameTestContext c){c.waitTicks(30);c.waitFor(m->m.levelRenderer.hasRenderedAllSections(),1200);c.waitTicks(8);}
private static void enable(ClientGameTestContext c,boolean enabled){c.runOnClient(m->VanillaLight.setSsgi(enabled));if(enabled)frames(c);else c.waitTicks(8);}
private static void frames(ClientGameTestContext c){int start=c.computeOnClient(m->ScreenSpaceGI.renderedFrames());c.waitFor(m->ScreenSpaceGI.applied()&&ScreenSpaceGI.renderedFrames()>start+10,1200);c.waitTicks(6);}
private static void preferences(){try{
var p=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");Files.writeString(p,"{\"bloom\":false,\"shadowPixels\":128}");forgetPreferences();
check(!VanillaLight.ssgi()&&VanillaLight.ssgiIntensity()==35&&!VanillaLight.bloom()&&VanillaLight.shadowPixels()==128,"Legacy choices preserved; experimental SSGI defaults OFF at 35");
for(int i:new int[]{-10,0,35,90,140}){VanillaLight.setSsgiIntensity(i);VanillaLight.setSsgi(true);forgetPreferences();check(VanillaLight.ssgi()&&VanillaLight.ssgiIntensity()==Math.clamp(i,0,100),"SSGI persists and clamps");}
VanillaLight.setSsgiIntensity(90);
}catch(Exception e){throw new AssertionError(e);}}
private record Gain(int changed,int visible,int strong,double red,double green,double blue){}
private static Gain measure(Frame off,Frame on){int count=0,visible=0,strong=0;double r=0,g=0,b=0;for(int i=0;i<off.pixels().length;i++){
int a=off.pixels()[i],ar=a>>16&255,ag=a>>8&255,ab=a&255;
if(Math.max(ar,Math.max(ag,ab))-Math.min(ar,Math.min(ag,ab))>25||ar<60||ar>245)continue;
int z=on.pixels()[i];int dr=(z>>16&255)-ar,dg=(z>>8&255)-ag,db=(z&255)-ab;
if(Math.max(dr,Math.max(dg,db))>=16)strong++;
if(Math.max(dr,Math.max(dg,db))>=8)visible++;
if(Math.max(dr,Math.max(dg,db))>1)count++;r+=Math.max(0,dr);g+=Math.max(0,dg);b+=Math.max(0,db);
}return new Gain(count,visible,strong,r,g,b);}
}
@@ -20,6 +20,7 @@ public final class SanctuaryKnowledgeClient implements ClientModInitializer {
SanctuaryResourcePack.register();
VortexCloudRenderer.register();
fr.koka.sanctuary.client.shader.PixelShadows.register();
fr.koka.sanctuary.client.shader.EdgeHighlights.register();
ProgressionClient.register();
StarClient.register();
WeatherClient.register();
@@ -0,0 +1,140 @@
package fr.koka.sanctuary.client.shader;
import com.mojang.blaze3d.buffers.Std140Builder;
import com.mojang.blaze3d.pipeline.TextureTarget;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.renderpearl.api.GpuFormat;
import com.mojang.renderpearl.api.buffers.GpuBuffer;
import com.mojang.renderpearl.api.buffers.GpuBufferSlice;
import com.mojang.renderpearl.api.pipeline.*;
import com.mojang.renderpearl.api.textures.FilterMode;
import com.mojang.renderpearl.api.textures.GpuTextureView;
import fr.koka.sanctuary.client.panorama.PanoramaCapture;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.BindGroupLayouts;
import net.minecraft.client.renderer.MappableRingBuffer;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.client.renderer.texture.TextureAtlas;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.dimension.DimensionType;
import net.minecraft.world.level.material.FogType;
import org.joml.Matrix4f;
import org.joml.Vector3f;
import org.joml.Vector4f;
/** Selective emissive bloom. No brightness threshold on ordinary world blocks, no scene-copy or readback. */
public final class Bloom {
private static Identifier id(String path){return Identifier.parse("sanctuary:"+path);}
private static final BindGroupLayout SCENE=BindGroupLayout.builder().withUniform("BloomScene",UniformType.UNIFORM_BUFFER)
.withUniform("WorldDepth",UniformType.COMBINED_IMAGE_SAMPLER).build();
private static final RenderPipeline EMITTERS=RenderPipelines.register(RenderPipeline.builder()
.withLocation(id("pipeline/bloom_emitters")).withVertexShader(id("core/bloom_emitters")).withFragmentShader(id("core/bloom_emitters"))
.withBindGroupLayout(BindGroupLayouts.DYNAMIC_TRANSFORMS).withBindGroupLayout(SCENE)
.withBindGroupLayout(BindGroupLayout.builder().withUniform("BlockAtlas",UniformType.COMBINED_IMAGE_SAMPLER).withUniform("EmissionMasks",UniformType.COMBINED_IMAGE_SAMPLER).build())
.withVertexBinding(0,BloomTerrain.FORMAT).withPrimitiveTopology(PrimitiveTopology.QUADS).withCull(false)
.withColorTargetState(new ColorTargetState(Optional.empty(),GpuFormat.RGBA8_UNORM,ColorTargetState.WRITE_ALL))
.withDepthStencilState(Optional.empty()).build());
private static RenderPipeline screen(String name,BlendFunction blend,boolean scene,boolean blur){
var layout=BindGroupLayout.builder().withUniform("InputTexture",UniformType.COMBINED_IMAGE_SAMPLER);
if(blur)layout.withUniform("BlurParams",UniformType.UNIFORM_BUFFER);
var builder=RenderPipeline.builder().withLocation(id("pipeline/"+name)).withVertexShader("core/screenquad").withFragmentShader(id("core/"+name)).withBindGroupLayout(layout.build());
if(scene)builder.withBindGroupLayout(SCENE);
return RenderPipelines.register(builder.withPrimitiveTopology(PrimitiveTopology.TRIANGLES).withCull(false)
.withColorTargetState(new ColorTargetState(Optional.ofNullable(blend),name.equals("bloom_blur")||name.equals("bloom_downsample")?GpuFormat.RGBA16_FLOAT:GpuFormat.RGBA8_UNORM,blend==null?ColorTargetState.WRITE_ALL:ColorTargetState.WRITE_COLOR)).withDepthStencilState(Optional.empty()).build());
}
private static final RenderPipeline SKY=screen("bloom_sky",null,true,false);
private static final RenderPipeline DOWNSAMPLE=screen("bloom_downsample",null,false,false);
private static final RenderPipeline BLUR=screen("bloom_blur",null,false,true);
private static final RenderPipeline CORE=screen("bloom_core",new BlendFunction(BlendFactor.ONE,BlendFactor.ONE_MINUS_SRC_ALPHA,BlendFactor.ZERO,BlendFactor.ONE),false,false);
private static final RenderPipeline APPLY=screen("bloom_apply",new BlendFunction(BlendFactor.ONE,BlendFactor.ONE_MINUS_SRC_COLOR,BlendFactor.ZERO,BlendFactor.ONE),true,false);
private static final BloomTerrain TERRAIN=new BloomTerrain();
private static final Matrix4f projection=new Matrix4f();
private static TextureTarget emission,small,ping;
private static MappableRingBuffer scene,horizontal,vertical;
private static int frames;
private static boolean applied;
private Bloom(){}
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 render(){
applied=false;var mc=Minecraft.getInstance();
boolean halo=VanillaLight.bloom()&&VanillaLight.bloomIntensity()>0;
boolean cores=VanillaLight.oreEmission()&&VanillaLight.oreIntensity()>0;
if(!VanillaLight.active()||(!halo&&!cores)||mc.level==null){release();return;}
if(PanoramaCapture.rendering())return;
var state=mc.gameRenderer.gameRenderState().levelRenderState;var camera=state.cameraRenderState;
if(camera.fogType!=FogType.NONE)return;
var main=mc.gameRenderer.mainRenderTarget();
if(emission==null||emission.width!=main.width||emission.height!=main.height){
targetsClose();emission=target("emission",main.width,main.height);
}
if(scene==null)scene=uniforms("bloom scene",144);
if(halo){
if(small==null){small=target("bloom low",Math.max(1,main.width/8),Math.max(1,main.height/8));ping=target("bloom ping",small.width,small.height);}
if(horizontal==null){horizontal=uniforms("bloom horizontal",16);vertical=uniforms("bloom vertical",16);}
}else haloClose();
var entries=TERRAIN.update();var atlas=TERRAIN.materials.atlas();
var vp=new Matrix4f(projection).mul(camera.viewRotationMatrix);var inverse=new Matrix4f(vp).invert();
var sun=PixelShadows.sunDirection(state.skyRenderState.sunAngle);var moon=PixelShadows.sunDirection(state.skyRenderState.moonAngle);
float celestial=state.skyRenderState.skybox==DimensionType.Skybox.OVERWORLD?state.skyRenderState.rainBrightness:0;
var fog=camera.fogData;scene.rotate();
try(var mapped=scene.currentBuffer().map(false,true)){
Std140Builder.intoBuffer(mapped.data()).putMat4f(inverse)
.putVec4(VanillaLight.oreEmission()?VanillaLight.oreIntensity()/100f:0,RenderSystem.getDevice().getDeviceInfo().isZZeroToOne()?1:0,VanillaLight.bloomIntensity()/50f,0)
.putVec4(sun.x,sun.y,sun.z,celestial).putVec4(moon.x,moon.y,moon.z,celestial*.35f)
.putVec4(fog.renderDistanceStart,fog.renderDistanceEnd,fog.environmentalStart,fog.environmentalEnd).putVec4(fog.color.w,0,0,0);
}
if(halo){
float radius=.25f+VanillaLight.bloomRadius()*.0075f;
horizontal.rotate();try(var mapped=horizontal.currentBuffer().map(false,true)){Std140Builder.intoBuffer(mapped.data()).putVec4(radius/small.width,0,0,0);}
vertical.rotate();try(var mapped=vertical.currentBuffer().map(false,true)){Std140Builder.intoBuffer(mapped.data()).putVec4(0,radius/small.height,0,0);}
}
// Gather transforms before opening any pass (the native dynamic ring may resize).
var transforms=new ArrayList<GpuBufferSlice>(entries.size());int max=0;
for(var entry:entries){var pos=entry.origin();var offset=new Vector3f((float)(pos.getX()-camera.pos.x),(float)(pos.getY()-camera.pos.y),(float)(pos.getZ()-camera.pos.z));
transforms.add(RenderSystem.getDynamicUniforms().writeTransform(new Matrix4f(vp).translate(offset),new Vector4f(1,1,1,1),offset,new Matrix4f()));max=Math.max(max,entry.indices());}
var indices=RenderSystem.getSequentialBuffer(PrimitiveTopology.QUADS);indices.requestIndexCount(max);
var nearest=RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST);
if(halo)screen(SKY,emission.getColorTextureView(),main.getColorTextureView(),true,null);
else try(var clear=RenderSystem.getDevice().createCommandEncoder().createRenderPass(()->"Sanctuary clear emission",emission.getColorTextureView(),Optional.of(new Vector4f(0,0,0,0)))){ }
if(atlas!=null&&!entries.isEmpty())try(var pass=RenderSystem.getDevice().createCommandEncoder().createRenderPass(()->"Sanctuary visible emitters",emission.getColorTextureView(),Optional.empty())){
pass.setPipeline(RenderSystem.getCompiledPipeline(EMITTERS));pass.setUniform("BloomScene",scene.currentBuffer());pass.setUniform("WorldDepth",main.getDepthTextureView(),nearest);
pass.setUniform("BlockAtlas",mc.getTextureManager().getTexture(TextureAtlas.LOCATION_BLOCKS).getTextureView(),nearest);pass.setUniform("EmissionMasks",atlas.getTextureView(),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);}
}
if(halo){
screen(DOWNSAMPLE,small.getColorTextureView(),emission.getColorTextureView(),false,null);
screen(BLUR,ping.getColorTextureView(),small.getColorTextureView(),false,horizontal);
screen(BLUR,small.getColorTextureView(),ping.getColorTextureView(),false,vertical);
}
if(cores)screen(CORE,main.getColorTextureView(),emission.getColorTextureView(),false,null);
if(halo)screen(APPLY,main.getColorTextureView(),small.getColorTextureView(),true,null);
applied=true;frames++;
}
private static void screen(RenderPipeline pipeline,GpuTextureView output,GpuTextureView input,boolean useScene,MappableRingBuffer blur){
try(var pass=RenderSystem.getDevice().createCommandEncoder().createRenderPass(()->"Sanctuary bloom",output,Optional.empty())){
pass.setPipeline(RenderSystem.getCompiledPipeline(pipeline));pass.setUniform("InputTexture",input,RenderSystem.getSamplerCache().getClampToEdge(pipeline==CORE||pipeline==SKY?FilterMode.NEAREST:FilterMode.LINEAR));
if(useScene){pass.setUniform("BloomScene",scene.currentBuffer());pass.setUniform("WorldDepth",Minecraft.getInstance().gameRenderer.mainRenderTarget().getDepthTextureView(),RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST));}
if(blur!=null)pass.setUniform("BlurParams",blur.currentBuffer());pass.draw(3,1,0,0);
}
}
private static TextureTarget target(String name,int w,int h){return new TextureTarget("Sanctuary "+name,w,h,name.equals("emission")?GpuFormat.RGBA8_UNORM:GpuFormat.RGBA16_FLOAT,null);}
private static MappableRingBuffer uniforms(String name,int size){return new MappableRingBuffer(()->"Sanctuary "+name,GpuBuffer.USAGE_UNIFORM|GpuBuffer.USAGE_MAP_WRITE,size);}
private static void haloClose(){
if(small!=null){small.destroyBuffers();ping.destroyBuffers();small=ping=null;}
if(horizontal!=null){horizontal.close();vertical.close();horizontal=vertical=null;}
}
private static void targetsClose(){haloClose();if(emission!=null){emission.destroyBuffers();emission=null;}}
public static void release(){TERRAIN.close();targetsClose();if(scene!=null){scene.close();scene=null;}applied=false;}
static boolean haloAllocated(){return small!=null||horizontal!=null;}
static boolean applied(){return applied;}
static boolean allocated(){return emission!=null;}
static boolean ready(){return TERRAIN.settled();}
static int renderedFrames(){return frames;}
static TextureTarget emissionTarget(){return emission;}
}
@@ -0,0 +1,111 @@
package fr.koka.sanctuary.client.shader;
import com.mojang.blaze3d.platform.NativeImage;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.client.renderer.texture.TextureAtlasSprite;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.block.state.BlockState;
/** Analyse source texels once per resource reload, never rendered frames or GPU pixels. */
final class BloomMaterials implements AutoCloseable {
static final int TILE=68, INNER=64, SIZE=TILE*16, MAX_SPRITES=256;
record Slot(int x,int y,boolean empty) {
float u(float local){return (x+2+Math.clamp(local,0,1)*INNER)/SIZE;}
float v(float local){return (y+2+Math.clamp(local,0,1)*INNER)/SIZE;}
}
private final Map<String,Slot> slots=new HashMap<>();
private final Map<BlockState,Boolean> ores=new IdentityHashMap<>();
private final Map<Identifier,int[]> hosts=new HashMap<>();
private DynamicTexture atlas;
private boolean dirty;
private static final Set<String> ORES=Set.of("iron","copper","redstone","lapis","diamond","emerald","ruby","sapphire","quartz","gold");
boolean ore(BlockState state){return ores.computeIfAbsent(state,s->{
var id=BuiltInRegistries.BLOCK.getKey(s.getBlock());
if(!id.getNamespace().equals("minecraft")&&!id.getNamespace().equals("sanctuary"))return false;
String name=id.getPath().replaceFirst("^(deepslate_|nether_)","");
return name.endsWith("_ore")&&ORES.contains(name.substring(0,name.length()-4));
});}
boolean candidate(BlockState state){return ore(state)||state.getLightEmission()>0||state.emissiveRendering();}
Slot slot(TextureAtlasSprite sprite,boolean ore,boolean fullEmission){
String key=sprite.contents().name()+":"+ore+":"+fullEmission;
var cached=slots.get(key);if(cached!=null)return cached;
if(slots.size()>=MAX_SPRITES)return new Slot(0,0,true);
int index=slots.size(),x=(index%16)*TILE,y=(index/16)*TILE;
if(atlas==null){atlas=new DynamicTexture("Sanctuary emissive masks",SIZE,SIZE,false);atlas.getPixels().fillRect(0,0,SIZE,SIZE,0);}
boolean any=false;
var id=sprite.contents().name();
try(var image=read(id)){
int frameWidth=Math.min(image.getWidth(),sprite.contents().width()),frameHeight=Math.min(image.getHeight(),sprite.contents().height());
int w=Math.min(INNER,frameWidth),h=Math.min(INNER,frameHeight);
int[] colors=new int[w*h];for(int iy=0;iy<h;iy++)for(int ix=0;ix<w;ix++)colors[iy*w+ix]=image.getPixel(ix*frameWidth/w,iy*frameHeight/h);
int[] mask=mask(id,colors,w,h,ore,fullEmission);
// Standard optional _e texture takes precedence, including in personal resource packs.
var override=Minecraft.getInstance().getResourceManager().getResource(texture(id.withSuffix("_e")));
if(override.isPresent())try(var stream=override.get().open();var explicit=NativeImage.read(stream)){
for(int iy=0;iy<h;iy++)for(int ix=0;ix<w;ix++){
int c=explicit.getPixel(ix*explicit.getWidth()/w,iy*Math.min(explicit.getHeight(),frameHeight)/h);
mask[iy*w+ix]=(c>>>24)*Math.max((c>>16)&255,Math.max((c>>8)&255,c&255))/255;
}
}
for(int iy=0;iy<TILE;iy++)for(int ix=0;ix<TILE;ix++){
int value=mask[Math.clamp((iy-2)*h/INNER,0,h-1)*w+Math.clamp((ix-2)*w/INNER,0,w-1)];
any|=value>0;atlas.getPixels().setPixel(x+ix,y+iy,0xFF000000|value*0x010101);
}
}catch(java.io.IOException exception){
fr.koka.sanctuary.SanctuaryMod.LOGGER.warn("Cannot analyse emissive texture {}",id,exception);
}
var result=new Slot(x,y,!any);slots.put(key,result);dirty=true;return result;
}
int[] mask(Identifier id,int[] colors,int width,int height,boolean ore,boolean fullEmission){
int[] result=new int[colors.length];String name=id.getPath();
boolean quartz=name.contains("quartz"),nether=name.contains("nether_gold")||quartz;
int[] host=ore?host(nether?"netherrack":name.contains("deepslate")?"deepslate":"stone"):new int[0];
boolean structural=!ore&&!fullEmission&&(name.contains("furnace_side")||name.contains("furnace_top")
||name.contains("smoker_side")||name.contains("smoker_top")||name.contains("smoker_bottom")
||name.contains("campfire_log")&&!name.contains("lit")||name.contains("respawn_anchor_side")
||name.contains("respawn_anchor_bottom"));
for(int i=0;i<colors.length;i++){
int c=colors[i],r=c>>16&255,g=c>>8&255,b=c&255,max=Math.max(r,Math.max(g,b)),min=Math.min(r,Math.min(g,b));
if((c>>>24)<128||structural)continue;
if(ore){
int difference=255;
for(int base:host){int br=base>>16&255,bg=base>>8&255,bb=base&255;
difference=Math.min(difference,Math.max(Math.abs(r-br),Math.max(Math.abs(g-bg),Math.abs(b-bb))));}
boolean inclusion=nether?(quartz?g>r*.65&&b>r*.5&&max>110:r>110&&g>75&&g>b*1.45):max-min>=10;
if(inclusion&&difference>=10)result[i]=255;
}else{
// Luminosity is tied to the block state; cold/off variants never enter this path.
// Dark casings/wood are suppressed; live atlas colours and animation stay native.
float value=fullEmission?1:Math.clamp((max-100)/110f,0,1);
if(name.contains("torch")||name.contains("furnace")||name.contains("smoker")||name.contains("campfire"))
value*=Math.clamp((max-min-35)/65f,0,1);
result[i]=Math.round(value*255);
}
}
if(ore&&!nether){
// Retain pale/white highlights touching coloured inclusions, without growing into grey rock.
int[] seed=result.clone();
for(int y=0;y<height;y++)for(int x=0;x<width;x++)if(seed[y*width+x]==0){
int c=colors[y*width+x],r=c>>16&255,g=c>>8&255,b=c&255;
if((c>>>24)<128||Math.min(r,Math.min(g,b))<215)continue;
for(int dy=-1;dy<=1;dy++)for(int dx=-1;dx<=1;dx++)if(x+dx>=0&&x+dx<width&&y+dy>=0&&y+dy<height&&seed[(y+dy)*width+x+dx]>0)result[y*width+x]=255;
}
}
return result;
}
private int[] host(String name){return hosts.computeIfAbsent(Identifier.withDefaultNamespace("block/"+name),id->{
try(var image=read(id)){var values=new LinkedHashSet<Integer>();for(int y=0;y<Math.min(64,image.getHeight());y++)for(int x=0;x<Math.min(64,image.getWidth());x++)values.add(image.getPixel(x*image.getWidth()/Math.min(64,image.getWidth()),y*image.getHeight()/Math.min(64,image.getHeight())));return values.stream().mapToInt(Integer::intValue).toArray();}
catch(java.io.IOException exception){return new int[0];}
});}
static Identifier texture(Identifier id){return Identifier.fromNamespaceAndPath(id.getNamespace(),"textures/"+id.getPath()+".png");}
static NativeImage read(Identifier id)throws java.io.IOException{try(var stream=Minecraft.getInstance().getResourceManager().getResourceOrThrow(texture(id)).open()){return NativeImage.read(stream);}}
DynamicTexture atlas(){if(dirty){atlas.upload();dirty=false;}return atlas;}
int count(){return slots.size();}
public void close(){if(atlas!=null){atlas.close();atlas=null;}slots.clear();ores.clear();hosts.clear();dirty=false;}
}
@@ -0,0 +1,147 @@
package fr.koka.sanctuary.client.shader;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.blaze3d.vertex.BufferBuilder;
import com.mojang.blaze3d.vertex.ByteBufferBuilder;
import com.mojang.renderpearl.api.GpuFormat;
import com.mojang.renderpearl.api.buffers.GpuBuffer;
import com.mojang.renderpearl.api.pipeline.PrimitiveTopology;
import com.mojang.renderpearl.api.vertex.VertexFormat;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.model.geom.builders.UVPair;
import net.minecraft.client.renderer.block.dispatch.BlockStateModelPart;
import net.minecraft.client.renderer.texture.TextureAtlas;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.SectionPos;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.chunk.LevelChunkSection;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.phys.Vec3;
/** Native visible sections only, palette rejection and bounded incremental emitter meshing. */
final class BloomTerrain implements AutoCloseable {
static final VertexFormat FORMAT=VertexFormat.builder(0).addAttribute("Position",GpuFormat.RGB32_FLOAT)
.addAttribute("Color",GpuFormat.RGBA8_UNORM).addAttribute("UV0",GpuFormat.RG32_FLOAT)
.addAttribute("UV3",GpuFormat.RG32_FLOAT).build();
private static final long MAX_BYTES=32L*1024*1024;
private final Map<Long,Section> sections=new HashMap<>();
final BloomMaterials materials=new BloomMaterials();
private Build building;
private long bytes;
private Object models,level;
private List<Section> visible=List.of();
record Entry(BlockPos origin,GpuBuffer vertices,int indices){}
List<Entry> update(){
var mc=Minecraft.getInstance();var current=mc.getModelManager().getBlockStateModelSet();
if(models!=current||level!=mc.level){close();models=current;level=mc.level;}
long frame=mc.level.getGameTime();var active=new ArrayList<Section>();
for(var render:mc.levelRenderer.visibleSections()){
long key=render.getSectionNode();var pos=render.getRenderOrigin();
var chunk=mc.level.getChunkSource().getChunk(pos.getX()>>4,pos.getZ()>>4,ChunkStatus.FULL,false);
if(chunk==null)continue;
var storage=chunk.getSection(mc.level.getSectionIndex(pos.getY()));
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);}
section.seen=frame;active.add(section);
}
visible=active;
for(var iterator=sections.values().iterator();iterator.hasNext();){var section=iterator.next();if(frame-section.seen>40){remove(section);iterator.remove();}}
Vec3 camera=mc.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.pos;
long deadline=System.nanoTime()+3_000_000L;int started=building==null?0:1;
while(System.nanoTime()<deadline){
if(building==null){
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;}
building=new Build(next);
}
if(!advance(building,deadline))break;
var done=building;building=null;
try(done){
done.section.dirty=false;
if(done.quads>0&&!done.overflow){
try(var mesh=done.out.buildOrThrow()){
long size=mesh.vertexBuffer().remaining();
if(bytes+size<=MAX_BYTES){
var buffer=RenderSystem.getDevice().createBuffer(()->"Sanctuary bloom emitters",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();
}
}
}
}
}
return active.stream().map(s->s.entry).filter(Objects::nonNull).toList();
}
private boolean advance(Build build,long deadline){
var mc=Minecraft.getInstance();var models=mc.getModelManager().getBlockStateModelSet();
var parts=new ArrayList<BlockStateModelPart>();var random=RandomSource.create();
while(build.cursor<4096){
int i=build.cursor++,x=i&15,z=i>>4&15,y=i>>8;var state=build.section.storage.getBlockState(x,y,z);
if(materials.candidate(state)){
var pos=build.section.origin.offset(x,y,z);boolean ore=materials.ore(state);
parts.clear();random.setSeed(state.getSeed(pos));if(!state.liquid())models.get(state).collectParts(random,parts);
var tintSources=mc.getBlockColors().getTintSources(state);
var offset=state.getOffset(pos);
for(var part:parts)for(int f=0;f<7;f++){
Direction face=f==6?null:Direction.values()[f];
if(face!=null&&mc.level.getBlockState(pos.relative(face)).isSolidRender())continue;
for(var quad:part.getQuads(face)){
var material=quad.materialInfo();var sprite=material.sprite();
if(!sprite.atlasLocation().equals(TextureAtlas.LOCATION_BLOCKS))continue;
var slot=materials.slot(sprite,ore,material.lightEmission()>0||state.emissiveRendering()||sprite.isAnimated());
if(slot.empty())continue;
if(build.quads>=16384){build.overflow=true;return true;}
int tint=0xFFFFFF,index=material.tintIndex();
if(index>=0&&index<tintSources.size())tint=tintSources.get(index).colorInWorld(state,mc.level,pos)&0xFFFFFF;
// Alpha marks ore vs. native emitter; the mask alpha is not a gameplay light value.
int light=state.emissiveRendering()?15:Math.max(state.getLightEmission(),material.lightEmission());
int alpha=ore?Math.round(light/15f*120):128+Math.round(light/15f*127);
int color=(alpha<<24)|tint;
for(int v=0;v<4;v++){
var p=quad.position(v);float u=UVPair.unpackU(quad.packedUV(v)),tv=UVPair.unpackV(quad.packedUV(v));
float localU=(u-sprite.getU0())/(sprite.getU1()-sprite.getU0()),localV=(tv-sprite.getV0())/(sprite.getV1()-sprite.getV0());
build.out.addVertex(x+p.x()+(float)offset.x,y+p.y()+(float)offset.y,z+p.z()+(float)offset.z)
.setColor(color).setUv(u,tv).setUv3(slot.u(localU),slot.v(localV));
}
build.quads++;
}
}
if(state.liquid()&&state.getLightEmission()>0)fluid(build,pos,x,y,z,state.getFluidState().getHeight(mc.level,pos));
}
if((build.cursor&31)==0&&System.nanoTime()>=deadline)return build.cursor==4096;
}
return true;
}
private void fluid(Build build,BlockPos pos,int x,int y,int z,float height){
var mc=Minecraft.getInstance();var sprite=((TextureAtlas)mc.getTextureManager().getTexture(TextureAtlas.LOCATION_BLOCKS)).getSprite(net.minecraft.resources.Identifier.withDefaultNamespace("block/lava_still"));
var slot=materials.slot(sprite,false,true);if(slot.empty())return;
// Native world depth retains the actual fluid silhouette and occludes these candidate faces.
float[][][] faces={{{0,height,0},{0,height,1},{1,height,1},{1,height,0}},{{0,0,0},{0,height,0},{1,height,0},{1,0,0}},
{{0,0,1},{1,0,1},{1,height,1},{0,height,1}},{{0,0,0},{0,0,1},{0,height,1},{0,height,0}},{{1,0,0},{1,height,0},{1,height,1},{1,0,1}}};
for(var face:faces){if(build.quads>=16384){build.overflow=true;return;}
for(int i=0;i<4;i++){float u=i>=2?1:0,v=i==1||i==2?1:0;var p=face[i];
build.out.addVertex(x+p[0],y+p[1],z+p[2]).setColor(0xFFFFFFFF).setUv(sprite.getU(u),sprite.getV(v)).setUv3(slot.u(u),slot.v(v));}
build.quads++;
}
}
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;}}
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;
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);
int cursor,quads;boolean overflow;
Build(Section section){this.section=section;}
public void close(){buffer.close();}
}
}
@@ -0,0 +1,99 @@
package fr.koka.sanctuary.client.shader;
import com.mojang.blaze3d.buffers.Std140Builder;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.renderpearl.api.GpuFormat;
import com.mojang.renderpearl.api.buffers.GpuBuffer;
import com.mojang.renderpearl.api.pipeline.*;
import com.mojang.renderpearl.api.textures.FilterMode;
import fr.koka.sanctuary.client.panorama.PanoramaCapture;
import java.util.Optional;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.MappableRingBuffer;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.material.FogType;
import org.joml.Matrix4f;
/** Optional, texture-preserving highlights on visible voxel edges. No gameplay light changes. */
public final class EdgeHighlights {
// Destination * (1 + highlight): retain the block's existing colour and texture, including shadows.
private static final RenderPipeline APPLY = RenderPipelines.register(RenderPipeline.builder()
.withLocation(Identifier.parse("sanctuary:pipeline/edge_highlights"))
.withVertexShader("core/screenquad")
.withFragmentShader(Identifier.parse("sanctuary:core/edge_highlights"))
.withBindGroupLayout(BindGroupLayout.builder()
.withUniform("EdgeHighlights", UniformType.UNIFORM_BUFFER)
.withUniform("WorldDepth", UniformType.COMBINED_IMAGE_SAMPLER).build())
.withPrimitiveTopology(PrimitiveTopology.TRIANGLES).withCull(false)
.withColorTargetState(new ColorTargetState(Optional.of(new BlendFunction(
BlendFactor.DST_COLOR, BlendFactor.ONE, BlendFactor.ZERO, BlendFactor.ONE)),
GpuFormat.RGBA8_UNORM, ColorTargetState.WRITE_COLOR))
.withDepthStencilState(Optional.empty()).build());
private static final Matrix4f projection = new Matrix4f();
private static MappableRingBuffer uniforms;
private static boolean applied;
private static int renderedFrames;
private EdgeHighlights() { }
public static void register() {
ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> release());
ClientLifecycleEvents.CLIENT_STOPPING.register(client -> release());
}
public static void captureProjection(Matrix4f matrix) { projection.set(matrix); }
/** Runs after cast shadows and before the hand and HUD; works without the shadow depth map. */
public static void render() {
applied = false;
var client = Minecraft.getInstance();
if (!VanillaLight.active() || !VanillaLight.edgeHighlights() || VanillaLight.edgeIntensity() == 0
|| client.level == null) {
release();
return;
}
if (PanoramaCapture.rendering()) return;
var camera = client.gameRenderer.gameRenderState().levelRenderState.cameraRenderState;
if (camera.fogType != FogType.NONE) return;
if (uniforms == null) uniforms = new MappableRingBuffer(() -> "Sanctuary edge highlights",
GpuBuffer.USAGE_UNIFORM | GpuBuffer.USAGE_MAP_WRITE, 176);
var viewProjection = new Matrix4f(projection).mul(camera.viewRotationMatrix);
var inverse = new Matrix4f(viewProjection).invert();
var fog = camera.fogData;
uniforms.rotate();
try (var mapped = uniforms.currentBuffer().map(false, true)) {
Std140Builder.intoBuffer(mapped.data()).putMat4f(inverse).putMat4f(viewProjection)
.putVec4((float)(camera.pos.x - Math.floor(camera.pos.x)),
(float)(camera.pos.y - Math.floor(camera.pos.y)),
(float)(camera.pos.z - Math.floor(camera.pos.z)), VanillaLight.shadowPixels())
.putVec4(VanillaLight.edgeIntensity() * .004f,
RenderSystem.getDevice().getDeviceInfo().isZZeroToOne() ? 1 : 0,
fog.renderDistanceStart, fog.renderDistanceEnd)
.putVec4(fog.environmentalStart, fog.environmentalEnd, fog.color.w, VanillaLight.edgeDistance());
}
var main = client.gameRenderer.mainRenderTarget();
var encoder = RenderSystem.getDevice().createCommandEncoder();
try (var pass = encoder.createRenderPass(() -> "Sanctuary edge highlights",
main.getColorTextureView(), Optional.empty())) {
pass.setPipeline(RenderSystem.getCompiledPipeline(APPLY));
pass.setUniform("EdgeHighlights", uniforms.currentBuffer());
pass.setUniform("WorldDepth", main.getDepthTextureView(),
RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST));
pass.draw(3, 1, 0, 0);
}
renderedFrames++;
applied = true;
}
public static void release() {
if (uniforms != null) { uniforms.close(); uniforms = null; }
applied = false;
}
static boolean applied() { return applied; }
static boolean allocated() { return uniforms != null; }
static int renderedFrames() { return renderedFrames; }
}
@@ -0,0 +1,65 @@
package fr.koka.sanctuary.client.shader;
import com.mojang.blaze3d.platform.NativeImage;
import java.util.*;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.client.renderer.texture.TextureAtlasSprite;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.world.level.block.state.BlockState;
/** One source-texture analysis per cached material. No frame readback and no generated files on disk. */
final class PbrMaterials implements AutoCloseable {
static final int INNER=16,TILE=20,GRID=64,SIZE=TILE*GRID,MAX_SPRITES=GRID*GRID;
record Profile(float roughness,float metal){}
record Slot(int x,int y,boolean empty){
float u(float f){return (x+2+Math.clamp(f,0,1)*INNER)/SIZE;}
float v(float f){return (y+2+Math.clamp(f,0,1)*INNER)/SIZE;}
}
private final Map<String,Slot> slots=new HashMap<>();
private final Map<BlockState,Profile> profiles=new IdentityHashMap<>();
private DynamicTexture atlas;private boolean dirty;private int analyses;
boolean candidate(BlockState state){return !state.isAir()&&!state.liquid();}
Profile profile(BlockState state){return profiles.computeIfAbsent(state,s->profile(BuiltInRegistries.BLOCK.getKey(s.getBlock()).getPath()));}
static Profile profile(String name){
if(name.equals("iron_block")||name.equals("gold_block")||name.equals("netherite_block")||name.contains("copper")&&!name.contains("ore")&&!name.contains("raw"))
return new Profile(name.contains("oxidized")?.65f:.32f,name.contains("oxidized")?.35f:.85f);
if(name.contains("polished")||name.contains("glazed")||name.equals("diamond_block")||name.equals("emerald_block")||name.equals("ruby_block")||name.equals("sapphire_block"))return new Profile(.42f,0);
if(name.contains("wool")||name.contains("leaves"))return new Profile(.98f,0);
if(name.contains("planks")||name.contains("log")||name.contains("wood"))return new Profile(.88f,0);
return new Profile(.82f,0);
}
Slot slot(TextureAtlasSprite sprite,BlockState state){
var profile=profile(state);String key=sprite.contents().name()+":"+profile;
var existing=slots.get(key);if(existing!=null)return existing;
if(slots.size()>=MAX_SPRITES)return new Slot(0,0,true);
int i=slots.size(),x=i%GRID*TILE,y=i/GRID*TILE;
if(atlas==null){atlas=new DynamicTexture("Sanctuary procedural PBR",SIZE,SIZE,false);atlas.getPixels().fillRect(0,0,SIZE,SIZE,0x008080FF);}
boolean empty=false;
try(var image=BloomMaterials.read(sprite.contents().name())){
int fw=Math.min(image.getWidth(),sprite.contents().width()),fh=Math.min(image.getHeight(),sprite.contents().height());
int[] colors=new int[INNER*INNER];
for(int py=0;py<INNER;py++)for(int px=0;px<INNER;px++)colors[py*INNER+px]=image.getPixel(px*fw/INNER,py*fh/INNER);
int[] pixels=generate(colors,INNER,INNER,profile);analyses++;
for(int py=0;py<TILE;py++)for(int px=0;px<TILE;px++)atlas.getPixels().setPixel(x+px,y+py,pixels[Math.clamp(py-2,0,INNER-1)*INNER+Math.clamp(px-2,0,INNER-1)]);
}catch(java.io.IOException e){empty=true;fr.koka.sanctuary.SanctuaryMod.LOGGER.warn("Cannot analyse PBR texture {}",sprite.contents().name(),e);}
var result=new Slot(x,y,empty);slots.put(key,result);dirty=true;return result;
}
static int[] generate(int[] colors,int w,int h,Profile profile){
int[] result=new int[colors.length];
for(int y=0;y<h;y++)for(int x=0;x<w;x++){
int center=colors[y*w+x];float base=luma(center);
float left=height(colors[y*w+Math.floorMod(x-1,w)],base),right=height(colors[y*w+(x+1)%w],base);
float up=height(colors[Math.floorMod(y-1,h)*w+x],base),down=height(colors[(y+1)%h*w+x],base);
float nx=Math.clamp((left-right)*2,-.45f,.45f),ny=Math.clamp((up-down)*2,-.45f,.45f);
if((center>>>24)<128){nx=ny=0;}
int red=Math.round((nx*.5f+.5f)*255),green=Math.round((ny*.5f+.5f)*255);
result[y*w+x]=(Math.round(profile.metal*255)<<24)|(red<<16)|(green<<8)|Math.round(profile.roughness*255);
}
return result;
}
private static float height(int color,float fallback){return (color>>>24)<128?fallback:luma(color);}
private static float luma(int c){return ((c>>16&255)*.2126f+(c>>8&255)*.7152f+(c&255)*.0722f)/255;}
DynamicTexture atlas(){if(dirty){atlas.upload();dirty=false;}return atlas;}
int count(){return slots.size();}int analyses(){return analyses;}
public void close(){if(atlas!=null){atlas.close();atlas=null;}slots.clear();profiles.clear();dirty=false;analyses=0;}
}
@@ -0,0 +1,135 @@
package fr.koka.sanctuary.client.shader;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.blaze3d.vertex.BufferBuilder;
import com.mojang.blaze3d.vertex.ByteBufferBuilder;
import com.mojang.renderpearl.api.GpuFormat;
import com.mojang.renderpearl.api.buffers.GpuBuffer;
import com.mojang.renderpearl.api.pipeline.PrimitiveTopology;
import com.mojang.renderpearl.api.vertex.VertexFormat;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.model.geom.builders.UVPair;
import net.minecraft.client.renderer.block.dispatch.BlockStateModelPart;
import net.minecraft.client.renderer.texture.TextureAtlas;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.SectionPos;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.chunk.LevelChunkSection;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.phys.Vec3;
/** Nearby native sections, bounded incremental meshing; models and resource UV rotations remain native. */
final class PbrTerrain implements AutoCloseable {
static final VertexFormat FORMAT=VertexFormat.builder(0).addAttribute("Position",GpuFormat.RGB32_FLOAT)
.addAttribute("Color",GpuFormat.RGBA8_UNORM).addAttribute("UV0",GpuFormat.RG32_FLOAT)
.addAttribute("UV3",GpuFormat.RG32_FLOAT).build();
private static final long MAX_BYTES=24L*1024*1024;
private final Map<Long,Section> sections=new HashMap<>();
final PbrMaterials materials=new PbrMaterials();
private Build building;
private long bytes;
private Object models,level;
private List<Section> visible=List.of();
record Entry(BlockPos origin,GpuBuffer vertices,int indices){}
List<Entry> update(){
var mc=Minecraft.getInstance();var current=mc.getModelManager().getBlockStateModelSet();
if(models!=current||level!=mc.level){close();models=current;level=mc.level;}
Vec3 camera=mc.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.pos;
long frame=mc.level.getGameTime();var active=new ArrayList<Section>();
for(var render:mc.levelRenderer.visibleSections()){
long key=render.getSectionNode();var pos=render.getRenderOrigin();
if(pos.offset(8,8,8).distToCenterSqr(camera)>48*48)continue;
var chunk=mc.level.getChunkSource().getChunk(pos.getX()>>4,pos.getZ()>>4,ChunkStatus.FULL,false);
if(chunk==null)continue;
var storage=chunk.getSection(mc.level.getSectionIndex(pos.getY()));
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);}
section.seen=frame;active.add(section);
}
visible=active;
for(var iterator=sections.values().iterator();iterator.hasNext();){var section=iterator.next();if(frame-section.seen>40){remove(section);iterator.remove();}}
long deadline=System.nanoTime()+3_000_000L;int started=building==null?0:1;
while(System.nanoTime()<deadline){
if(building==null){
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;}
building=new Build(next);
}
if(!advance(building,deadline))break;
var done=building;building=null;
try(done){
done.section.dirty=false;
if(done.quads>0&&!done.overflow){
try(var mesh=done.out.buildOrThrow()){
long size=mesh.vertexBuffer().remaining();
if(bytes+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();
}
}
}
}
}
return active.stream().map(s->s.entry).filter(Objects::nonNull).toList();
}
private boolean advance(Build build,long deadline){
var mc=Minecraft.getInstance();var models=mc.getModelManager().getBlockStateModelSet();
var parts=new ArrayList<BlockStateModelPart>();var random=RandomSource.create();
while(build.cursor<4096){
int i=build.cursor++,x=i&15,z=i>>4&15,y=i>>8;var state=build.section.storage.getBlockState(x,y,z);
if(materials.candidate(state)){
var pos=build.section.origin.offset(x,y,z);
parts.clear();random.setSeed(state.getSeed(pos));if(!state.liquid())models.get(state).collectParts(random,parts);
var tintSources=mc.getBlockColors().getTintSources(state);
var offset=state.getOffset(pos);
for(var part:parts)for(int f=0;f<7;f++){
Direction face=f==6?null:Direction.values()[f];
if(face!=null&&mc.level.getBlockState(pos.relative(face)).isSolidRender())continue;
for(var quad:part.getQuads(face)){
var material=quad.materialInfo();var sprite=material.sprite();
if(!sprite.atlasLocation().equals(TextureAtlas.LOCATION_BLOCKS))continue;
var slot=materials.slot(sprite,state);
if(slot.empty())continue;
if(build.quads>=16384){build.overflow=true;return true;}
int tint=0xFFFFFF,index=material.tintIndex();
if(index>=0&&index<tintSources.size())tint=tintSources.get(index).colorInWorld(state,mc.level,pos)&0xFFFFFF;
int sky=mc.level.getBrightness(net.minecraft.world.level.LightLayer.SKY,face==null?pos:pos.relative(face));
int color=(Math.round(sky/15f*255)<<24)|tint;
for(int v=0;v<4;v++){
var p=quad.position(v);float u=UVPair.unpackU(quad.packedUV(v)),tv=UVPair.unpackV(quad.packedUV(v));
float localU=(u-sprite.getU0())/(sprite.getU1()-sprite.getU0()),localV=(tv-sprite.getV0())/(sprite.getV1()-sprite.getV0());
build.out.addVertex(x+p.x()+(float)offset.x,y+p.y()+(float)offset.y,z+p.z()+(float)offset.z)
.setColor(color).setUv(u,tv).setUv3(slot.u(localU),slot.v(localV));
}
build.quads++;
}
}
}
if(System.nanoTime()>=deadline)return build.cursor==4096;
}
return true;
}
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;}}
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;
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);
int cursor,quads;boolean overflow;
Build(Section section){this.section=section;}
public void close(){buffer.close();}
}
}
@@ -51,6 +51,7 @@ public final class PixelShadows {
.withDepthStencilState(Optional.empty()).build());
private static final ShadowTerrain TERRAIN = new ShadowTerrain();
private static final Matrix4f projection = new Matrix4f();
private static final ShadowProjection lightGrid = new ShadowProjection();
private static TextureTarget shadowMap;
private static MappableRingBuffer uniforms;
private static int renderedFrames;
@@ -86,7 +87,7 @@ public final class PixelShadows {
if (lunar) sun = sunDirection(sky.moonAngle);
if (sky.skybox != DimensionType.Skybox.OVERWORLD || camera.fogType != FogType.NONE || sun.y <= .08f) return;
float moon = sky.moonPhase == null ? 1 : (float)(.5 + .5 * Math.cos(sky.moonPhase.index() * Math.PI / 4));
float strength = (lunar ? .10f * moon : .38f * zenithFade(sun.y))
float strength = (lunar ? .10f * moon : .38f)
* Math.clamp((sun.y - .08f) / .16f, 0, 1) * sky.rainBrightness;
if (strength <= .001f) return;
int distance = VanillaLight.shadowDistance(), pixels = VanillaLight.shadowPixels();
@@ -100,7 +101,7 @@ public final class PixelShadows {
GpuBuffer.USAGE_UNIFORM | GpuBuffer.USAGE_MAP_WRITE, 192);
double ox = anchor(camera.pos.x), oy = anchor(camera.pos.y), oz = anchor(camera.pos.z);
boolean zeroToOne = RenderSystem.getDevice().getDeviceInfo().isZZeroToOne();
var light = lightMatrix(sun, distance, size, ox, oy, oz, zeroToOne);
var light = lightGrid.matrix(sun, distance, size, ox, oy, oz, zeroToOne);
var inverse = new Matrix4f(projection).mul(camera.viewRotationMatrix).invert();
uniforms.rotate();
var fog = camera.fogData;
@@ -152,33 +153,18 @@ public final class PixelShadows {
applied = true;
}
/** Smoothly soften overhead sunlight, without a noon on/off discontinuity. */
static float zenithFade(float elevation) {
float t = Math.clamp((elevation - .92f) / .08f, 0, 1);
return 1 - .75f * t * t * (3 - 2 * t);
}
static double anchor(double coordinate) { return Math.floor(coordinate / 16.0 + .5) * 16.0; }
static com.mojang.renderpearl.api.buffers.GpuBuffer materialUniforms(){return uniforms.currentBuffer();}
static com.mojang.renderpearl.api.textures.GpuTextureView materialDepth(){return shadowMap.getDepthTextureView();}
static Vector3f sunDirection(float angle) { return new Vector3f(-(float)Math.sin(angle), (float)Math.cos(angle), 0); }
static int mapSize(int distance, int pixels) {
int size = 512;
while (size < 2 * (distance + 16) * pixels && size < 4096) size *= 2;
return size;
}
static Matrix4f lightMatrix(Vector3f sun, int distance, int size, double x, double y, double z, boolean zeroToOne) {
float extent = distance + 16, reach = 4 * (distance + 16);
// A fixed up axis is nonsingular even at noon. Snap the light-space origin to texels.
var view = new Matrix4f().lookAt(sun.x * reach, sun.y * reach, sun.z * reach, 0, 0, 0, 0, 0, 1);
double texel = 2.0 * extent / size;
double sx = view.m00() * x + view.m10() * y + view.m20() * z;
double sy = view.m01() * x + view.m11() * y + view.m21() * z;
var result = new Matrix4f().setOrtho(-extent, extent, -extent, extent, .1f, reach * 2, zeroToOne).mul(view);
result.m30(result.m30() + (float)((sx - Math.rint(sx / texel) * texel) / extent));
result.m31(result.m31() + (float)((sy - Math.rint(sy / texel) * texel) / extent));
return result;
}
public static void release() {
TERRAIN.close();
lightGrid.reset();
if (shadowMap != null) { shadowMap.destroyBuffers(); shadowMap = null; }
if (uniforms != null) { uniforms.close(); uniforms = null; }
applied = false;
@@ -0,0 +1,93 @@
package fr.koka.sanctuary.client.shader;
import com.mojang.blaze3d.buffers.Std140Builder;
import com.mojang.blaze3d.pipeline.TextureTarget;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.renderpearl.api.GpuFormat;
import com.mojang.renderpearl.api.buffers.GpuBuffer;
import com.mojang.renderpearl.api.buffers.GpuBufferSlice;
import com.mojang.renderpearl.api.pipeline.*;
import com.mojang.renderpearl.api.textures.FilterMode;
import fr.koka.sanctuary.client.panorama.PanoramaCapture;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.BindGroupLayouts;
import net.minecraft.client.renderer.MappableRingBuffer;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.client.renderer.texture.TextureAtlas;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.dimension.DimensionType;
import net.minecraft.world.level.material.FogType;
import org.joml.Matrix4f;
import org.joml.Vector3f;
import org.joml.Vector4f;
/** Optional local material pass: cached texture normals, GGX highlights and diffuse relief. */
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("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"))
.withFragmentShader(Identifier.parse("sanctuary:core/pbr")).withBindGroupLayout(BindGroupLayouts.DYNAMIC_TRANSFORMS).withBindGroupLayout(SCENE)
.withVertexBinding(0,PbrTerrain.FORMAT).withPrimitiveTopology(PrimitiveTopology.QUADS).withCull(false)
.withColorTargetState(new ColorTargetState(Optional.empty(),GpuFormat.RGBA8_UNORM,ColorTargetState.WRITE_COLOR))
.withDepthStencilState(Optional.empty()).build());
private static final PbrTerrain TERRAIN=new PbrTerrain();
private static final Matrix4f projection=new Matrix4f();
private static TextureTarget source;
private static MappableRingBuffer scene;
private static boolean applied;private static int frames;
private ProceduralPbr(){}
public static void captureProjection(Matrix4f matrix){projection.set(matrix);}
public static void dirtyBlock(BlockPos pos){TERRAIN.dirty(pos);}
public static void dirtyChunk(int x,int z){TERRAIN.dirtyChunk(x,z);}
public static void dirtySection(int x,int y,int z){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;}
var state=mc.gameRenderer.gameRenderState().levelRenderState;var camera=state.cameraRenderState;var sky=state.skyRenderState;
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);
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;
if(lunar)sun=PixelShadows.sunDirection(sky.moonAngle);
float moon=sky.moonPhase==null?1:(float)(.5+.5*Math.cos(sky.moonPhase.index()*Math.PI/4));
float strength=(lunar?.12f*moon:1)*sky.rainBrightness*Math.clamp((sun.y-.04f)/.16f,0,1);
boolean shadow=PixelShadows.applied();
scene.rotate();try(var mapped=scene.currentBuffer().map(false,true)){
Std140Builder.intoBuffer(mapped.data()).putMat4f(new Matrix4f(vp).invert())
.putVec4(sun.x,sun.y,sun.z,strength)
.putVec4(VanillaLight.pbrIntensity()/100f,RenderSystem.getDevice().getDeviceInfo().isZZeroToOne()?1:0,shadow?1:0,32)
.putVec4(fog.renderDistanceStart,fog.renderDistanceEnd,fog.environmentalStart,fog.environmentalEnd).putVec4(fog.color.w,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;
for(var entry:entries){var p=entry.origin();var offset=new Vector3f((float)(p.getX()-camera.pos.x),(float)(p.getY()-camera.pos.y),(float)(p.getZ()-camera.pos.z));
transforms.add(RenderSystem.getDynamicUniforms().writeTransform(new Matrix4f(vp).translate(offset),new Vector4f(1,1,1,1),offset,new Matrix4f()));max=Math.max(max,entry.indices());}
var indices=RenderSystem.getSequentialBuffer(PrimitiveTopology.QUADS);indices.requestIndexCount(max);
var encoder=RenderSystem.getDevice().createCommandEncoder();
encoder.copyTextureToTexture(main.getColorTexture(),source.getColorTexture(),0,0,0,0,0,main.width,main.height);
var nearest=RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST);
if(atlas!=null&&!entries.isEmpty())try(var pass=encoder.createRenderPass(()->"Sanctuary PBR materials",main.getColorTextureView(),Optional.empty())){
pass.setPipeline(RenderSystem.getCompiledPipeline(PIPELINE));pass.setUniform("PbrScene",scene.currentBuffer());
pass.setUniform("PixelShadows",shadow?PixelShadows.materialUniforms():scene.currentBuffer());
pass.setUniform("SunDepth",shadow?PixelShadows.materialDepth():main.getDepthTextureView(),nearest);
pass.setUniform("WorldDepth",main.getDepthTextureView(),nearest);pass.setUniform("SceneColor",source.getColorTextureView(),nearest);
pass.setUniform("BlockAtlas",mc.getTextureManager().getTexture(TextureAtlas.LOCATION_BLOCKS).getTextureView(),nearest);
pass.setUniform("MaterialAtlas",atlas.getTextureView(),nearest);pass.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++;
}
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 materialCount(){return TERRAIN.materials.count();}static int analyses(){return TERRAIN.materials.analyses();}
}
@@ -0,0 +1,100 @@
package fr.koka.sanctuary.client.shader;
import com.mojang.blaze3d.buffers.Std140Builder;
import com.mojang.blaze3d.pipeline.TextureTarget;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.renderpearl.api.GpuFormat;
import com.mojang.renderpearl.api.buffers.GpuBuffer;
import com.mojang.renderpearl.api.pipeline.*;
import com.mojang.renderpearl.api.textures.FilterMode;
import fr.koka.sanctuary.client.panorama.PanoramaCapture;
import java.util.Optional;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.MappableRingBuffer;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.material.FogType;
import org.joml.Matrix4f;
/** Bounded, single-bounce diffuse lighting from this frame's visible surfaces. No GPU readback or history. */
public final class ScreenSpaceGI {
private static final BindGroupLayout LAYOUT=BindGroupLayout.builder()
.withUniform("GIParams",UniformType.UNIFORM_BUFFER)
.withUniform("WorldDepth",UniformType.COMBINED_IMAGE_SAMPLER)
.withUniform("SceneColor",UniformType.COMBINED_IMAGE_SAMPLER)
.withUniform("IndirectLight",UniformType.COMBINED_IMAGE_SAMPLER).build();
private static RenderPipeline pipeline(String name,boolean composite){
return RenderPipelines.register(RenderPipeline.builder().withLocation(Identifier.parse("sanctuary:pipeline/"+name))
.withVertexShader("core/screenquad").withFragmentShader(Identifier.parse("sanctuary:core/"+name))
.withBindGroupLayout(LAYOUT).withBindGroupLayout(BindGroupLayout.builder().withUniform("GIBlur",UniformType.UNIFORM_BUFFER).build()).withPrimitiveTopology(PrimitiveTopology.TRIANGLES).withCull(false)
.withColorTargetState(new ColorTargetState(composite?Optional.of(new BlendFunction(BlendFactor.ONE,BlendFactor.ZERO,BlendFactor.ZERO,BlendFactor.ONE)):Optional.empty(),
composite?GpuFormat.RGBA8_UNORM:GpuFormat.RGBA16_FLOAT,composite?ColorTargetState.WRITE_COLOR:ColorTargetState.WRITE_ALL))
.withDepthStencilState(Optional.empty()).build());
}
private static final RenderPipeline GATHER=pipeline("ssgi_gather",false),APPLY=pipeline("ssgi_apply",true),FILTER=pipeline("ssgi_filter",false);
private static final Matrix4f projection=new Matrix4f();
private static TextureTarget source,indirect,filtered;
private static MappableRingBuffer uniforms,horizontal,vertical;
private static boolean applied;
private static int frames;
private ScreenSpaceGI(){}
public static void captureProjection(Matrix4f matrix){projection.set(matrix);}
public static void render(){
applied=false;var mc=Minecraft.getInstance();
if(!VanillaLight.active()||!VanillaLight.ssgi()||VanillaLight.ssgiIntensity()==0||mc.level==null){release();return;}
if(PanoramaCapture.rendering())return;
var camera=mc.gameRenderer.gameRenderState().levelRenderState.cameraRenderState;
if(camera.fogType!=FogType.NONE)return;
var main=mc.gameRenderer.mainRenderTarget();
if(source==null||source.width!=main.width||source.height!=main.height){
release();source=new TextureTarget("Sanctuary SSGI source",main.width,main.height,GpuFormat.RGBA8_UNORM,null);
indirect=new TextureTarget("Sanctuary SSGI indirect",Math.max(1,(main.width+1)/2),Math.max(1,(main.height+1)/2),GpuFormat.RGBA16_FLOAT,null);
filtered=new TextureTarget("Sanctuary SSGI filtered",indirect.width,indirect.height,GpuFormat.RGBA16_FLOAT,null);
}
if(uniforms==null){
uniforms=new MappableRingBuffer(()->"Sanctuary SSGI",GpuBuffer.USAGE_UNIFORM|GpuBuffer.USAGE_MAP_WRITE,176);
horizontal=new MappableRingBuffer(()->"Sanctuary SSGI filter X",GpuBuffer.USAGE_UNIFORM|GpuBuffer.USAGE_MAP_WRITE,16);
vertical=new MappableRingBuffer(()->"Sanctuary SSGI filter Y",GpuBuffer.USAGE_UNIFORM|GpuBuffer.USAGE_MAP_WRITE,16);
}
horizontal.rotate();try(var m=horizontal.currentBuffer().map(false,true)){Std140Builder.intoBuffer(m.data()).putVec4(1,0,0,0);}
vertical.rotate();try(var m=vertical.currentBuffer().map(false,true)){Std140Builder.intoBuffer(m.data()).putVec4(0,1,0,0);}
var vp=new Matrix4f(projection).mul(camera.viewRotationMatrix);var fog=camera.fogData;
uniforms.rotate();try(var mapped=uniforms.currentBuffer().map(false,true)){
Std140Builder.intoBuffer(mapped.data()).putMat4f(new Matrix4f(vp).invert()).putMat4f(vp)
.putVec4(VanillaLight.ssgiIntensity()/100f,RenderSystem.getDevice().getDeviceInfo().isZZeroToOne()?1:0,2.5f,32)
.putVec4(fog.renderDistanceStart,fog.renderDistanceEnd,fog.environmentalStart,fog.environmentalEnd)
.putVec4(fog.color.w,0,0,0);
}
// Snapshot once, before adding indirect light: no feedback, no texture sampled while attached for writing.
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 SSGI gather",indirect.getColorTextureView(),Optional.empty())){
pass.setPipeline(RenderSystem.getCompiledPipeline(GATHER));pass.setUniform("GIBlur",horizontal.currentBuffer());pass.setUniform("GIParams",uniforms.currentBuffer());
pass.setUniform("WorldDepth",main.getDepthTextureView(),nearest);pass.setUniform("SceneColor",source.getColorTextureView(),nearest);
// Unused by gather; bind a separate texture, never its own output.
pass.setUniform("IndirectLight",source.getColorTextureView(),nearest);pass.draw(3,1,0,0);
}
filter(encoder,indirect,filtered,horizontal);
filter(encoder,filtered,indirect,vertical);
try(var pass=encoder.createRenderPass(()->"Sanctuary SSGI composite",main.getColorTextureView(),Optional.empty())){
pass.setPipeline(RenderSystem.getCompiledPipeline(APPLY));pass.setUniform("GIBlur",horizontal.currentBuffer());pass.setUniform("GIParams",uniforms.currentBuffer());
pass.setUniform("WorldDepth",main.getDepthTextureView(),nearest);pass.setUniform("SceneColor",source.getColorTextureView(),nearest);
pass.setUniform("IndirectLight",indirect.getColorTextureView(),nearest);pass.draw(3,1,0,0);
}
applied=true;frames++;
}
private static void filter(com.mojang.renderpearl.api.commands.CommandEncoder encoder,TextureTarget input,TextureTarget output,MappableRingBuffer direction){
var main=Minecraft.getInstance().gameRenderer.mainRenderTarget();var nearest=RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST);
try(var pass=encoder.createRenderPass(()->"Sanctuary SSGI bilateral filter",output.getColorTextureView(),Optional.empty())){
pass.setPipeline(RenderSystem.getCompiledPipeline(FILTER));pass.setUniform("GIParams",uniforms.currentBuffer());pass.setUniform("GIBlur",direction.currentBuffer());
pass.setUniform("WorldDepth",main.getDepthTextureView(),nearest);pass.setUniform("SceneColor",source.getColorTextureView(),nearest);
pass.setUniform("IndirectLight",input.getColorTextureView(),RenderSystem.getSamplerCache().getClampToEdge(FilterMode.LINEAR));pass.draw(3,1,0,0);
}
}
public static void release(){if(source!=null){source.destroyBuffers();indirect.destroyBuffers();filtered.destroyBuffers();source=indirect=filtered=null;}if(uniforms!=null){uniforms.close();horizontal.close();vertical.close();uniforms=horizontal=vertical=null;}applied=false;}
static boolean applied(){return applied;}
static boolean allocated(){return source!=null;}
static int renderedFrames(){return frames;}
}
@@ -17,14 +17,41 @@ public final class ShaderOptionsScreen extends OptionsSubScreen {
list.addBig(new OptionInstance<Integer>("sanctuary.shaders.saturation",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.saturation_help")),(caption,value)->Component.translatable("sanctuary.shaders.saturation_value",value),
new OptionInstance.IntRange(0,200),VanillaLight.saturation(),VanillaLight::setSaturation));
list.addBig(new OptionInstance<Integer>("sanctuary.shaders.precision",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.precision_help")),(caption,value)->Component.translatable("sanctuary.shaders.precision_value",value),
new OptionInstance.Enum<>(List.of(8,16,32,64,128),Codec.INT),VanillaLight.shadowPixels(),VanillaLight::setShadowPixels));
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.pbr",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.pbr_help")),VanillaLight.pbr(),VanillaLight::setPbr));
slider("pbr_intensity",VanillaLight.pbrIntensity(),VanillaLight::setPbrIntensity);
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.ssgi",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.ssgi_help")),VanillaLight.ssgi(),VanillaLight::setSsgi));
slider("ssgi_intensity",VanillaLight.ssgiIntensity(),VanillaLight::setSsgiIntensity);
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.bloom",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.bloom_help")),VanillaLight.bloom(),VanillaLight::setBloom));
slider("bloom_intensity",VanillaLight.bloomIntensity(),VanillaLight::setBloomIntensity);
slider("bloom_radius",VanillaLight.bloomRadius(),VanillaLight::setBloomRadius);
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.ore_emission",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.ore_emission_help")),VanillaLight.oreEmission(),VanillaLight::setOreEmission));
slider("ore_intensity",VanillaLight.oreIntensity(),VanillaLight::setOreIntensity);
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.edge_highlights",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.edge_highlights_help")),
VanillaLight.edgeHighlights(),VanillaLight::setEdgeHighlights));
list.addBig(new OptionInstance<Integer>("sanctuary.shaders.edge_intensity",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.edge_intensity_help")),(caption,value)->Component.translatable("sanctuary.shaders.edge_intensity_value",value),
new OptionInstance.IntRange(0,100),VanillaLight.edgeIntensity(),VanillaLight::setEdgeIntensity));
list.addBig(new OptionInstance<Integer>("sanctuary.shaders.edge_distance",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.edge_distance_help")),(caption,value)->Component.translatable("sanctuary.shaders.edge_distance_value",value),
new OptionInstance.Enum<>(List.of(16,32,64,128,256),Codec.INT),VanillaLight.edgeDistance(),VanillaLight::setEdgeDistance));
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.pixel_shadows",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.pixel_shadows_help")),
VanillaLight.pixelShadows(),VanillaLight::setPixelShadows));
list.addBig(new OptionInstance<Integer>("sanctuary.shaders.shadow_pixels",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.shadow_pixels_help")),(caption,value)->Component.translatable("sanctuary.shaders.shadow_pixels_value",value),
new OptionInstance.Enum<>(List.of(8,16,32),Codec.INT),VanillaLight.shadowPixels(),VanillaLight::setShadowPixels));
list.addBig(new OptionInstance<Integer>("sanctuary.shaders.shadow_distance",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.shadow_distance_help")),(caption,value)->Component.translatable("sanctuary.shaders.shadow_distance_value",value),
new OptionInstance.Enum<>(List.of(16,32,64),Codec.INT),VanillaLight.shadowDistance(),VanillaLight::setShadowDistance));
new OptionInstance.Enum<>(List.of(16,32,64,128,256),Codec.INT),VanillaLight.shadowDistance(),VanillaLight::setShadowDistance));
}
private void slider(String key,int value,java.util.function.Consumer<Integer> setter){
list.addBig(new OptionInstance<Integer>("sanctuary.shaders."+key,OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders."+key+"_help")),(caption,v)->Component.translatable("sanctuary.shaders."+key+"_value",v),
new OptionInstance.IntRange(0,100),value,setter::accept));
}
}
@@ -0,0 +1,37 @@
package fr.koka.sanctuary.client.shader;
import org.joml.Matrix4f;
import org.joml.Vector3f;
/** Keeps the raster grid continuous as the sun moves; only camera recentering snaps to texels. */
final class ShadowProjection {
private boolean initialized;
private int distance, size;
private double x, y, z, phaseX, phaseY;
Matrix4f matrix(Vector3f sun, int distance, int size, double x, double y, double z, boolean zeroToOne) {
float extent = distance + 16, reach = 4 * (distance + 16);
var view = new Matrix4f().lookAt(sun.x * reach, sun.y * reach, sun.z * reach,
0, 0, 0, 0, 0, 1);
double texel = 2.0 * extent / size;
if (!initialized || this.distance != distance || this.size != size) {
phaseX = phaseY = 0;
} else if (this.x != x || this.y != y || this.z != z) {
double dx = x - this.x, dy = y - this.y, dz = z - this.z;
phaseX = Math.IEEEremainder(phaseX + view.m00() * dx + view.m10() * dy + view.m20() * dz, texel);
phaseY = Math.IEEEremainder(phaseY + view.m01() * dx + view.m11() * dy + view.m21() * dz, texel);
}
// Re-rounding the projected WORLD origin every frame made a slowly rotating light jump
// a raster texel periodically, particularly far from spawn. Retain this phase in between
// camera anchor changes: the light can rotate continuously without moving the raster origin.
initialized = true;
this.distance = distance; this.size = size;
this.x = x; this.y = y; this.z = z;
var result = new Matrix4f().setOrtho(-extent, extent, -extent, extent, .1f, reach * 2, zeroToOne).mul(view);
result.m30(result.m30() + (float)(phaseX / extent));
result.m31(result.m31() + (float)(phaseY / extent));
return result;
}
void reset() { initialized = false; }
}
@@ -39,8 +39,8 @@ import org.joml.Vector3fc;
* A different ClientLevel, or update(null, ...), also releases all owned resources.
*/
public final class ShadowTerrain implements AutoCloseable {
/** Bounds metadata/work as well as geometry; current shader options use 16, 32 and 64. */
public static final int MAX_DISTANCE = 128;
/** Bounds metadata/work as well as geometry; shader options reach 256 blocks. */
public static final int MAX_DISTANCE = 256;
private static final int MAX_SECTIONS_PER_UPDATE = 4;
private static final long BUILD_NANOS = 3_000_000L;
private static final long MAX_GPU_BYTES = 96L * 1024 * 1024;
@@ -61,6 +61,8 @@ public final class ShadowTerrain implements AutoCloseable {
private long gpuBytes;
private List<Entry> entries = List.of();
private boolean entriesChanged;
private long reconciledTick = Long.MIN_VALUE;
private boolean metadataDirty = true;
private int cameraSectionX, cameraSectionY, cameraSectionZ;
/**
@@ -108,7 +110,12 @@ public final class ShadowTerrain implements AutoCloseable {
cameraSectionX = cx;
cameraSectionY = cy;
cameraSectionZ = cz;
reconcile();
// Large ranges must not rescan loaded chunk metadata at the display's frame rate.
if (moved || metadataDirty || reconciledTick != level.getGameTime()) {
reconcile();
reconciledTick = level.getGameTime();
metadataDirty = false;
}
if (moved) {
retryDeferred();
}
@@ -172,11 +179,13 @@ public final class ShadowTerrain implements AutoCloseable {
RenderSystem.assertOnRenderThread();
Section section = sections.get(SectionPos.asLong(sx, sy, sz));
if (section != null) dirty(section);
else metadataDirty = true;
}
/** Coordinates are chunk coordinates. Caller also invalidates adjacent chunks as needed. */
public void dirtyChunk(int cx, int cz) {
RenderSystem.assertOnRenderThread();
metadataDirty = true;
for (Section section : sections.values()) {
if (section.x == cx && section.z == cz) dirty(section);
}
@@ -406,6 +415,8 @@ public final class ShadowTerrain implements AutoCloseable {
for (Section section : sections.values()) release(section);
sections.clear();
chunks.clear();
reconciledTick = Long.MIN_VALUE;
metadataDirty = true;
shapes.clear();
entries = List.of();
entriesChanged = false;
@@ -17,9 +17,21 @@ public final class VanillaLight {
private static final class Preferences {
boolean enabled=true;
int saturation=142;
boolean pixelShadows=false;
boolean pixelShadows=true;
boolean bloom=true;
boolean pbr=false;
int pbrIntensity=50;
boolean ssgi=false;
int ssgiIntensity=35;
int bloomIntensity=35;
int bloomRadius=50;
boolean oreEmission=true;
int oreIntensity=50;
boolean edgeHighlights=true;
int edgeIntensity=30;
int edgeDistance=32;
int shadowPixels=16;
int shadowDistance=32;
int shadowDistance=128;
}
private static Path path(){return FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");}
public static boolean enabled(){
@@ -27,30 +39,69 @@ public final class VanillaLight {
try{if(Files.isRegularFile(path()))preferences=new GsonBuilder().create().fromJson(Files.readString(path()),Preferences.class);}
catch(Exception e){SanctuaryMod.LOGGER.warn("Cannot read Sanctuary shader preference",e);}
if(preferences==null)preferences=new Preferences();
preferences.pbrIntensity=Math.clamp(preferences.pbrIntensity,0,100);
preferences.ssgiIntensity=Math.clamp(preferences.ssgiIntensity,0,100);
preferences.bloomIntensity=Math.clamp(preferences.bloomIntensity,0,100);
preferences.bloomRadius=Math.clamp(preferences.bloomRadius,0,100);
preferences.oreIntensity=Math.clamp(preferences.oreIntensity,0,100);
preferences.edgeIntensity=Math.clamp(preferences.edgeIntensity,0,100);
preferences.edgeDistance=normalizeShadowDistance(preferences.edgeDistance);
preferences.saturation=Math.clamp(preferences.saturation,0,200);
preferences.shadowPixels=normalizeShadowValue(preferences.shadowPixels,8,16,32);
preferences.shadowDistance=normalizeShadowValue(preferences.shadowDistance,16,32,64);
preferences.shadowPixels=normalizePrecision(preferences.shadowPixels);
preferences.shadowDistance=normalizeShadowDistance(preferences.shadowDistance);
}
return preferences.enabled;
}
public static boolean externalEngine(){return EXTERNAL_ENGINE;}
public static boolean active(){return ProgressionClient.active()&&!EXTERNAL_ENGINE&&enabled();}
public static int saturation(){enabled();return preferences.saturation;}
public static boolean edgeHighlights(){enabled();return preferences.edgeHighlights;}
public static int edgeDistance(){enabled();return preferences.edgeDistance;}
public static void setEdgeDistance(int value){enabled();preferences.edgeDistance=normalizeShadowDistance(value);save();}
public static int edgeIntensity(){enabled();return preferences.edgeIntensity;}
public static void setEdgeHighlights(boolean value){enabled();preferences.edgeHighlights=value;save();}
public static void setEdgeIntensity(int value){enabled();preferences.edgeIntensity=Math.clamp(value,0,100);save();}
public static boolean pbr(){enabled();return preferences.pbr;}
public static int pbrIntensity(){enabled();return preferences.pbrIntensity;}
public static void setPbr(boolean value){enabled();preferences.pbr=value;save();}
public static void setPbrIntensity(int value){enabled();preferences.pbrIntensity=Math.clamp(value,0,100);save();}
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();}
public static void setSsgiIntensity(int value){enabled();preferences.ssgiIntensity=Math.clamp(value,0,100);save();}
public static boolean bloom(){enabled();return preferences.bloom;}
public static int bloomIntensity(){enabled();return preferences.bloomIntensity;}
public static int bloomRadius(){enabled();return preferences.bloomRadius;}
public static boolean oreEmission(){enabled();return preferences.oreEmission;}
public static int oreIntensity(){enabled();return preferences.oreIntensity;}
public static void setBloom(boolean value){enabled();preferences.bloom=value;save();}
public static void setBloomIntensity(int value){enabled();preferences.bloomIntensity=Math.clamp(value,0,100);save();}
public static void setBloomRadius(int value){enabled();preferences.bloomRadius=Math.clamp(value,0,100);save();}
public static void setOreEmission(boolean value){enabled();preferences.oreEmission=value;save();}
public static void setOreIntensity(int value){enabled();preferences.oreIntensity=Math.clamp(value,0,100);save();}
public static boolean pixelShadows(){enabled();return preferences.pixelShadows;}
public static int shadowPixels(){enabled();return preferences.shadowPixels;}
public static int shadowDistance(){enabled();return preferences.shadowDistance;}
public static void setSaturation(int value){enabled();preferences.saturation=Math.clamp(value,0,200);save();}
public static void setPixelShadows(boolean value){enabled();preferences.pixelShadows=value;save();}
public static void setShadowPixels(int value){enabled();preferences.shadowPixels=normalizeShadowValue(value,8,16,32);save();}
public static void setShadowDistance(int value){enabled();preferences.shadowDistance=normalizeShadowValue(value,16,32,64);save();}
public static void setShadowPixels(int value){enabled();preferences.shadowPixels=normalizePrecision(value);save();}
public static void setShadowDistance(int value){enabled();preferences.shadowDistance=normalizeShadowDistance(value);save();}
public static void setEnabled(boolean value){
enabled();preferences.enabled=value;
save();
}
/** Nearest supported value, clamped at either end; midpoint ties round up. */
private static int normalizeShadowValue(int value,int low,int middle,int high){
if(value<(low+middle)/2)return low;
return value<(middle+high)/2?middle:high;
private static int normalizePrecision(int value){
if(value<12)return 8;
if(value<24)return 16;
if(value<48)return 32;
return value<96?64:128;
}
private static int normalizeShadowDistance(int value){
if(value<24)return 16;
if(value<48)return 32;
if(value<96)return 64;
return value<192?128:256;
}
private static void save(){
try{
@@ -1,6 +1,9 @@
package fr.koka.sanctuary.mixin.client;
import fr.koka.sanctuary.client.shader.PixelShadows;
import fr.koka.sanctuary.client.shader.ProceduralPbr;
import fr.koka.sanctuary.client.shader.Bloom;
import fr.koka.sanctuary.client.shader.ScreenSpaceGI;
import net.minecraft.client.renderer.extract.LevelExtractor;
import net.minecraft.core.BlockPos;
import org.spongepowered.asm.mixin.Mixin;
@@ -13,8 +16,10 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
abstract class PixelShadowDirtyMixin {
@Inject(method="blockChanged", at=@At("HEAD"))
private void sanctuary$dirty(BlockPos pos, int flags, CallbackInfo ci) {
PixelShadows.dirtyBlock(pos);
PixelShadows.dirtyBlock(pos); ProceduralPbr.dirtyBlock(pos); Bloom.dirtyBlock(pos);
}
@Inject(method="allChanged", at=@At("HEAD"))
private void sanctuary$reload(CallbackInfo ci) { PixelShadows.release(); }
private void sanctuary$reload(CallbackInfo ci) { PixelShadows.release(); ProceduralPbr.release(); Bloom.release(); ScreenSpaceGI.release(); }
@Inject(method="setSectionDirty", at=@At("HEAD"))
private void sanctuary$pbrLight(int x,int y,int z,CallbackInfo ci){ProceduralPbr.dirtySection(x,y,z);}
}
@@ -1,6 +1,8 @@
package fr.koka.sanctuary.mixin.client;
import fr.koka.sanctuary.client.shader.PixelShadows;
import fr.koka.sanctuary.client.shader.ProceduralPbr;
import fr.koka.sanctuary.client.shader.Bloom;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.state.BlockState;
@@ -15,11 +17,11 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
abstract class PixelShadowLevelMixin {
@Inject(method="setBlocksDirty", at=@At("HEAD"))
private void sanctuary$block(BlockPos pos, BlockState before, BlockState after, CallbackInfo ci) {
if (before != after) PixelShadows.dirtyBlock(pos);
if (before != after) {PixelShadows.dirtyBlock(pos); ProceduralPbr.dirtyBlock(pos);Bloom.dirtyBlock(pos);}
}
@Inject(method="onChunkLoaded", at=@At("TAIL"))
private void sanctuary$loaded(ChunkPos pos, CallbackInfo ci) { PixelShadows.dirtyChunk(pos.x(), pos.z()); }
private void sanctuary$loaded(ChunkPos pos, CallbackInfo ci) { PixelShadows.dirtyChunk(pos.x(), pos.z()); ProceduralPbr.dirtyChunk(pos.x(), pos.z()); Bloom.dirtyChunk(pos.x(), pos.z()); }
@Inject(method="unload", at=@At("HEAD"))
private void sanctuary$unload(LevelChunk chunk, CallbackInfo ci) { PixelShadows.dirtyChunk(chunk.getPos().x(), chunk.getPos().z()); }
private void sanctuary$unload(LevelChunk chunk, CallbackInfo ci) { PixelShadows.dirtyChunk(chunk.getPos().x(), chunk.getPos().z()); ProceduralPbr.dirtyChunk(chunk.getPos().x(), chunk.getPos().z()); Bloom.dirtyChunk(chunk.getPos().x(), chunk.getPos().z()); }
}
@@ -4,6 +4,10 @@ import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
import com.mojang.renderpearl.api.buffers.GpuBufferSlice;
import fr.koka.sanctuary.client.shader.PixelShadows;
import fr.koka.sanctuary.client.shader.ProceduralPbr;
import fr.koka.sanctuary.client.shader.EdgeHighlights;
import fr.koka.sanctuary.client.shader.Bloom;
import fr.koka.sanctuary.client.shader.ScreenSpaceGI;
import net.minecraft.client.renderer.GameRenderer;
import net.minecraft.client.renderer.ProjectionMatrixBuffer;
import org.joml.Matrix4f;
@@ -17,10 +21,14 @@ abstract class PixelShadowRendererMixin {
@WrapOperation(method="renderLevel", at=@At(value="INVOKE", target="Lnet/minecraft/client/renderer/ProjectionMatrixBuffer;getBuffer(Lorg/joml/Matrix4f;)Lcom/mojang/renderpearl/api/buffers/GpuBufferSlice;"))
private GpuBufferSlice sanctuary$projection(ProjectionMatrixBuffer buffer, Matrix4f matrix, Operation<GpuBufferSlice> original) {
PixelShadows.captureProjection(matrix);
ProceduralPbr.captureProjection(matrix);
EdgeHighlights.captureProjection(matrix);
Bloom.captureProjection(matrix);
ScreenSpaceGI.captureProjection(matrix);
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(); }
private void sanctuary$shadows(CallbackInfo ci) { PixelShadows.render(); ProceduralPbr.render(); ScreenSpaceGI.render(); EdgeHighlights.render(); Bloom.render(); }
@Inject(method={"close", "setLevel"}, at=@At("HEAD"))
private void sanctuary$close(CallbackInfo ci) { PixelShadows.release(); }
private void sanctuary$close(CallbackInfo ci) { PixelShadows.release(); ProceduralPbr.release(); EdgeHighlights.release(); Bloom.release(); ScreenSpaceGI.release(); }
}
@@ -2257,12 +2257,46 @@
"block.sanctuary.deepslate_sapphire_ore": "Deepslate Sapphire Ore",
"sanctuary.discovery.granted": "Discoveries added for %s: %s blocks, %s items, %s species. Linked recipes updated; reopen catalogues.",
"sanctuary.discovery.invalid": "Choose a discoverable block, item or species.",
"sanctuary.shaders.edge_highlights": "Edge highlights",
"sanctuary.shaders.edge_highlights_help": "Connected highlights along terrain contours. Flat surfaces connect, even across different blocks. Preserves textures. ON by default; requires Vanilla Light.",
"sanctuary.shaders.edge_intensity": "Edge intensity",
"sanctuary.shaders.edge_intensity_help": "Highlight strength along edges. Independent of cast shadows.",
"sanctuary.shaders.edge_intensity_value": "Edge intensity: %s %%",
"sanctuary.shaders.pixel_shadows": "Pixel shadows",
"sanctuary.shaders.pixel_shadows_help": "Sun shadows and subtle moon shadows on a pixel grid. Dappled foliage and softer shadows at noon. Disabled by default; requires Vanilla Light.",
"sanctuary.shaders.pixel_shadows_help": "Sun shadows and subtle moon shadows on a pixel grid. Dappled foliage and stable contours. Enabled by default at 128 blocks; requires Vanilla Light.",
"sanctuary.shaders.shadow_pixels": "Shadow precision",
"sanctuary.shaders.shadow_pixels_value": "%s pixels/block",
"sanctuary.shaders.shadow_pixels_help": "Grid precision: 8, 16 or 32 pixels per block. Higher values make shadow pixels finer. Requires Vanilla Light and pixel shadows.",
"sanctuary.shaders.shadow_pixels_help": "Grid precision: 8, 16, 32, 64 or 128 pixels per block. Higher values make shadow pixels finer. Requires Vanilla Light and pixel shadows.",
"sanctuary.shaders.shadow_distance": "Shadow distance",
"sanctuary.shaders.shadow_distance_value": "%s blocks",
"sanctuary.shaders.shadow_distance_help": "Shadow range: 16, 32 or 64 blocks. Higher distances cost more performance. Requires Vanilla Light and pixel shadows."
"sanctuary.shaders.shadow_distance_help": "Shadow range: 16, 32, 64, 128 or 256 blocks, within loaded terrain. Higher distances cost more performance. Requires Vanilla Light and pixel shadows.",
"sanctuary.shaders.edge_distance": "Highlight distance",
"sanctuary.shaders.edge_distance_value": "%s blocks",
"sanctuary.shaders.edge_distance_help": "Independent range: 16, 32, 64, 128 or 256 blocks. Limited to visible terrain and render distance. Distant contours become naturally finer.",
"sanctuary.shaders.precision": "Precision",
"sanctuary.shaders.precision_help": "Shared grid for shadows and edge highlights: 8, 16, 32, 64 or 128 pixels per block. The shadow map retains its resolution cap.",
"sanctuary.shaders.precision_value": "%s pixels/block",
"sanctuary.shaders.bloom": "Bloom",
"sanctuary.shaders.bloom_help": "Glow around the sun and luminous surfaces. ON by default. Turning it off removes halos while keeping emissive ores. Requires Vanilla Light.",
"sanctuary.shaders.bloom_intensity": "Bloom strength",
"sanctuary.shaders.bloom_intensity_help": "Halo strength. Zero removes halos and their blur passes while keeping emissive ores.",
"sanctuary.shaders.bloom_intensity_value": "Bloom: %s%%",
"sanctuary.shaders.bloom_radius": "Bloom spread",
"sanctuary.shaders.bloom_radius_help": "Halo size around luminous pixels. Texture details stay sharp.",
"sanctuary.shaders.bloom_radius_value": "Spread: %s%%",
"sanctuary.shaders.ore_emission": "Emissive ores",
"sanctuary.shaders.ore_emission_help": "Makes ore inclusions glow while preserving their host rock, even with bloom off or at zero. ON by default. Visual effect only.",
"sanctuary.shaders.ore_intensity": "Ore emission",
"sanctuary.shaders.ore_intensity_help": "Inclusion brightness. Does not change world light levels or mob spawning.",
"sanctuary.shaders.ore_intensity_value": "Ores: %s%%",
"sanctuary.shaders.ssgi": "SSGI · color bounce",
"sanctuary.shaders.ssgi_help": "Experimental: enhanced color bounce between nearby visible surfaces. Radius 2.5 blocks. Off-screen surfaces do not contribute. OFF by default.",
"sanctuary.shaders.ssgi_intensity": "SSGI strength",
"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. OFF 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%%"
}
@@ -2257,12 +2257,46 @@
"block.sanctuary.deepslate_sapphire_ore": "Minerai de saphir des abîmes",
"sanctuary.discovery.granted": "Découvertes ajoutées pour %s : %s blocs, %s objets, %s espèces. Recettes liées actualisées ; rouvre les catalogues.",
"sanctuary.discovery.invalid": "Choisis un bloc, un objet ou une espèce découvrable.",
"sanctuary.shaders.edge_highlights": "Reflets sur les arêtes",
"sanctuary.shaders.edge_highlights_help": "Reflets connectés sur les contours du relief. Les surfaces plates se raccordent, même entre blocs différents. Préserve les textures. Activé par défaut ; nécessite Vanilla Light.",
"sanctuary.shaders.edge_intensity": "Intensité des arêtes",
"sanctuary.shaders.edge_intensity_help": "Force du reflet sur les arêtes. Le réglage est indépendant des ombres portées.",
"sanctuary.shaders.edge_intensity_value": "Intensité des arêtes : %s %%",
"sanctuary.shaders.pixel_shadows": "Ombres pixelisées",
"sanctuary.shaders.pixel_shadows_help": "Ombres du soleil et ombres légères de la lune, sur une grille de pixels. Feuillage irrégulier et ombres atténuées à midi. Désactivées par défaut ; nécessite Vanilla Light.",
"sanctuary.shaders.pixel_shadows_help": "Ombres du soleil et ombres légères de la lune, sur une grille de pixels. Feuillage irrégulier et contours stables. Activées par défaut, portée 128 blocs ; nécessite Vanilla Light.",
"sanctuary.shaders.shadow_pixels": "Précision des ombres",
"sanctuary.shaders.shadow_pixels_value": "%s pixels/bloc",
"sanctuary.shaders.shadow_pixels_help": "Précision de la grille : 8, 16 ou 32 pixels par bloc. Plus la valeur est élevée, plus les pixels des ombres sont fins. Nécessite Vanilla Light et les ombres pixelisées.",
"sanctuary.shaders.shadow_pixels_help": "Précision de la grille : 8, 16, 32, 64 ou 128 pixels par bloc. Plus la valeur est élevée, plus les pixels des ombres sont fins. Nécessite Vanilla Light et les ombres pixelisées.",
"sanctuary.shaders.shadow_distance": "Distance des ombres",
"sanctuary.shaders.shadow_distance_value": "%s blocs",
"sanctuary.shaders.shadow_distance_help": "Portée des ombres : 16, 32 ou 64 blocs. Une distance plus élevée coûte davantage en performances. Nécessite Vanilla Light et les ombres pixelisées."
"sanctuary.shaders.shadow_distance_help": "Portée des ombres : 16, 32, 64, 128 ou 256 blocs, dans le terrain chargé. Une distance élevée coûte davantage en performances. Nécessite Vanilla Light et les ombres pixelisées.",
"sanctuary.shaders.edge_distance": "Portée des arêtes",
"sanctuary.shaders.edge_distance_value": "%s blocs",
"sanctuary.shaders.edge_distance_help": "Portée indépendante : 16, 32, 64, 128 ou 256 blocs. Limitée au terrain visible et à la distance de rendu. Les contours lointains saffinent naturellement.",
"sanctuary.shaders.precision": "Précision",
"sanctuary.shaders.precision_help": "Grille commune aux ombres et aux reflets sur les arêtes : 8, 16, 32, 64 ou 128 pixels par bloc. La carte dombres conserve sa résolution maximale.",
"sanctuary.shaders.precision_value": "%s pixels/bloc",
"sanctuary.shaders.bloom": "Bloom",
"sanctuary.shaders.bloom_help": "Halo du soleil et des surfaces lumineuses. Activé par défaut. Larrêt coupe le halo et conserve les minerais émissifs. Nécessite Vanilla Light.",
"sanctuary.shaders.bloom_intensity": "Intensité du bloom",
"sanctuary.shaders.bloom_intensity_help": "Force du halo. Zéro supprime le halo et ses passes de diffusion, tout en conservant les minerais émissifs.",
"sanctuary.shaders.bloom_intensity_value": "Bloom : %s %%",
"sanctuary.shaders.bloom_radius": "Diffusion du bloom",
"sanctuary.shaders.bloom_radius_help": "Taille du halo autour des pixels lumineux. Les détails des textures restent nets.",
"sanctuary.shaders.bloom_radius_value": "Diffusion : %s %%",
"sanctuary.shaders.ore_emission": "Minerais émissifs",
"sanctuary.shaders.ore_emission_help": "Fait briller les inclusions des minerais sans éclairer leur roche, même avec le bloom coupé ou à zéro. Activé par défaut. Effet visuel uniquement.",
"sanctuary.shaders.ore_intensity": "Émission des minerais",
"sanctuary.shaders.ore_intensity_help": "Luminosité des inclusions. Aucun changement de la lumière du monde ou des apparitions de mobs.",
"sanctuary.shaders.ore_intensity_value": "Minerais : %s %%",
"sanctuary.shaders.ssgi": "SSGI · couleurs diffusées",
"sanctuary.shaders.ssgi_help": "Expérimental : diffusion renforcée des couleurs entre surfaces visibles proches. Rayon 2,5 blocs. Les surfaces hors écran ne contribuent pas. Désactivé par défaut.",
"sanctuary.shaders.ssgi_intensity": "Intensité du SSGI",
"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. Désactivé par défaut.",
"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 %%"
}
@@ -0,0 +1,7 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
#include <sanctuary:bloom_scene.glsl>
uniform sampler2D InputTexture;
layout(location=0) in vec2 texCoord;
layout(location=0) out vec4 fragColor;
void main(){vec3 halo=texture(InputTexture,texCoord).rgb;fragColor=vec4(clamp(halo*BloomParams.z,0.0,1.0),0.0);}
@@ -0,0 +1,13 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
uniform sampler2D InputTexture;
layout(std140) uniform BlurParams { vec4 Step; };
layout(location=0) in vec2 texCoord;
layout(location=0) out vec4 fragColor;
// At most one low-resolution texel between taps: broad halos cannot turn into a sparse grid.
void main(){
float weights[13]=float[13](0.099908358,0.096834501,0.088168817,0.075414785,0.060597482,0.045741379,0.032435495,0.021606698,0.013521126,0.007948660,0.004389667,0.002277329,0.001109882);
vec3 color=texture(InputTexture,texCoord).rgb*weights[0];
for(int i=1;i<=12;i++){vec2 d=Step.xy*float(i);color+=(texture(InputTexture,texCoord+d).rgb+texture(InputTexture,texCoord-d).rgb)*weights[i];}
fragColor=vec4(color,0.0);
}
@@ -0,0 +1,6 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
uniform sampler2D InputTexture;
layout(location=0) in vec2 texCoord;
layout(location=0) out vec4 fragColor;
void main(){vec4 emission=texture(InputTexture,texCoord);fragColor=emission.a>0.0?emission:vec4(0.0);}
@@ -0,0 +1,6 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
uniform sampler2D InputTexture;
layout(location=0) in vec2 texCoord;
layout(location=0) out vec4 fragColor;
void main(){vec2 pixel=1.0/vec2(textureSize(InputTexture,0));vec3 sum=vec3(0.0);for(int y=0;y<8;y++)for(int x=0;x<8;x++)sum+=texture(InputTexture,texCoord+(vec2(x,y)-3.5)*pixel).rgb;fragColor=vec4(sum/64.0,0.0);}
@@ -0,0 +1,22 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
#include <sanctuary:bloom_scene.glsl>
uniform sampler2D BlockAtlas;
uniform sampler2D EmissionMasks;
layout(location=0) in vec3 relativePosition;
layout(location=1) in vec2 blockUv;
layout(location=2) in vec2 maskUv;
layout(location=3) in vec4 tint;
layout(location=0) out vec4 fragColor;
void main(){
vec2 uv=gl_FragCoord.xy/vec2(textureSize(WorldDepth,0));
float depth=texture(WorldDepth,uv).r;
if(depth>0.0&&length(relativePosition)>length(bloomPosition(uv,depth))+0.02)discard;
vec4 source=texture(BlockAtlas,blockUv);
float mask=texture(EmissionMasks,maskUv).r;
bool ore=tint.a<0.5;
float nativeLight=ore?tint.a*(255.0/120.0):max(0.0,(tint.a*255.0-128.0)/127.0);
float amount=mask*(ore?max(BloomParams.x,nativeLight):nativeLight)*bloomFog(relativePosition);
if(source.a<0.5||amount<0.001)discard;
fragColor=vec4(source.rgb*tint.rgb*amount,ore?amount:0.0);
}
@@ -0,0 +1,12 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
#include <minecraft:dynamictransforms.glsl>
layout(location=0) in vec3 Position;
layout(location=1) in vec4 Color;
layout(location=2) in vec2 UV0;
layout(location=3) in vec2 UV3;
layout(location=0) out vec3 relativePosition;
layout(location=1) out vec2 blockUv;
layout(location=2) out vec2 maskUv;
layout(location=3) out vec4 tint;
void main(){gl_Position=ModelViewMat*vec4(Position,1.0);relativePosition=Position+ModelOffset;blockUv=UV0;maskUv=UV3;tint=Color;}
@@ -0,0 +1,15 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
#include <sanctuary:bloom_scene.glsl>
uniform sampler2D InputTexture;
layout(location=0) in vec2 texCoord;
layout(location=0) out vec4 fragColor;
void main(){
fragColor=vec4(0.0);
if(texture(WorldDepth,texCoord).r>0.0)return;
vec3 ray=normalize(bloomPosition(texCoord,0.00001));
float celestial=max(step(0.955,dot(ray,SunDirection.xyz))*SunDirection.w,step(0.975,dot(ray,MoonDirection.xyz))*MoonDirection.w);
vec3 color=texture(InputTexture,texCoord).rgb;
float luminance=dot(color,vec3(.2126,.7152,.0722));
fragColor=vec4(color*smoothstep(.72,.98,luminance)*celestial,0.0);
}
@@ -0,0 +1,71 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
uniform sampler2D WorldDepth;
layout(std140) uniform EdgeHighlights {
mat4 InverseViewProjection;
mat4 ViewProjection;
vec4 CameraGrid;
vec4 StrengthClipFog;
vec4 EnvironmentFog;
};
layout(location = 0) in vec2 texCoord;
layout(location = 0) out vec4 fragColor;
vec3 positionAt(vec2 uv, float depth) {
float z = StrengthClipFog.y > 0.5 ? depth : depth * 2.0 - 1.0;
vec4 p = InverseViewProjection * vec4(uv * 2.0 - 1.0, z, 1.0);
return p.xyz / p.w;
}
float exposed(vec3 probe, vec3 surface, vec3 normal) {
vec4 clip = ViewProjection * vec4(probe, 1.0);
if (clip.w <= 0.0) return 0.0;
vec2 uv = clip.xy / clip.w * 0.5 + 0.5;
if (any(lessThanEqual(uv, vec2(0.0))) || any(greaterThanEqual(uv, vec2(1.0)))) return 0.0;
// Reconstruct at the sampled depth texel's centre, not at a different subpixel position.
vec2 size = vec2(textureSize(WorldDepth, 0));
uv = (floor(uv * size) + 0.5) / size;
float depth = texture(WorldDepth, uv).r;
if (depth <= 0.0) return 1.0;
float behind = dot(positionAt(uv, depth) - surface, normal);
// A coplanar neighbouring block contributes zero. Concave corners are not glowing seams.
return 1.0 - smoothstep(-0.045, -0.020, behind);
}
float fogAmount(float distance, float start, float end) {
return clamp((distance - start) / max(end - start, 0.001), 0.0, 1.0);
}
void main() {
float depth = texture(WorldDepth, texCoord).r;
vec3 relative = positionAt(texCoord, depth);
vec3 dx = dFdx(relative), dy = dFdy(relative);
vec3 normal = normalize(cross(dx, dy));
if (dot(normal, -relative) < 0.0) normal = -normal;
vec3 an = abs(normal);
if (depth <= 0.0 || length(relative) >= EnvironmentFog.w || max(max(an.x, an.y), an.z) < 0.995) discard;
vec3 axis = an.x > an.y && an.x > an.z ? vec3(1,0,0) : an.y > an.z ? vec3(0,1,0) : vec3(0,0,1);
normal = axis * sign(dot(normal, axis));
vec3 tangent = axis.x > 0.5 ? vec3(0,0,1) : vec3(1,0,0);
vec3 bitangent = abs(cross(axis, tangent));
float width = 1.0 / CameraGrid.w;
vec3 p = relative + CameraGrid.xyz;
float plane = floor(dot(p, axis) * CameraGrid.w + 0.5) * width;
// Restrict to voxel planes; don't trace arbitrary moving models with a voxel-grid effect.
if (abs(dot(p, axis) - plane) > 0.003) discard;
vec3 q = (floor(p * CameraGrid.w + 0.0001) + 0.5) * width;
q += axis * (plane - dot(q, axis));
q -= CameraGrid.xyz;
float probeWidth = max(width, max(length(dx), length(dy)));
float edge = max(max(exposed(q + tangent * probeWidth, q, normal), exposed(q - tangent * probeWidth, q, normal)),
max(exposed(q + bitangent * probeWidth, q, normal), exposed(q - bitangent * probeWidth, q, normal)));
// Connected surfaces share their outline, irrespective of the block texture.
// Integrate subpixel strips as coverage rather than deleting distant CTM outlines.
float footprint = max(length(dx), length(dy));
float fade = min(1.0, width / max(footprint, width));
fade *= 1.0 - smoothstep(EnvironmentFog.w * 0.75, EnvironmentFog.w, length(relative));
float fog = max(fogAmount(length(relative), EnvironmentFog.x, EnvironmentFog.y),
fogAmount(max(length(relative.xz), abs(relative.y)), StrengthClipFog.z, StrengthClipFog.w));
float highlight = edge * fade * StrengthClipFog.x * (1.0 - fog * EnvironmentFog.z);
fragColor = vec4(vec3(highlight), 0.0);
}
@@ -0,0 +1,78 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
uniform sampler2D WorldDepth;
uniform sampler2D SceneColor;
uniform sampler2D BlockAtlas;
uniform sampler2D MaterialAtlas;
uniform sampler2D SunDepth;
layout(std140) uniform PbrScene {
mat4 PbrInverseVP;
vec4 PbrSun;
vec4 PbrParams;
vec4 PbrFog;
vec4 PbrEnvironment;
};
layout(std140) uniform PixelShadows {
mat4 InverseViewProjection;
mat4 LightMatrix;
vec4 CameraFractionPixels;
vec4 SunStrength;
vec4 DistanceClipFog;
vec4 EnvironmentFog;
};
layout(location=0) in vec3 relativePosition;
layout(location=1) in vec2 blockUv;
layout(location=2) in vec2 maskUv;
layout(location=3) in vec4 tint;
layout(location=0) out vec4 fragColor;
float visibility(vec3 p,vec3 n){
if(PbrParams.z<0.5)return 1.0;
vec3 world=p+CameraFractionPixels.xyz;
vec3 snapped=(floor(world*CameraFractionPixels.w+.0001)+.5)/CameraFractionPixels.w;
vec3 receiver=snapped+n*dot(world-snapped,n);
vec4 light=LightMatrix*vec4(receiver+n*0.04+PbrSun.xyz*0.015,1.0);
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;
}
void main(){
// Derivatives precede divergent discard; UV-space tangents preserve rotated/mirrored block textures.
vec3 dx=dFdx(relativePosition),dy=dFdy(relativePosition);
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;
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;
vec3 tangent=normalize((dx*ty.y-dy*tx.y)/det),bitangent=normalize((dy*tx.x-dx*ty.x)/det);
vec4 material=texture(MaterialAtlas,maskUv);
// 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);
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;
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;
// 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));
lit+=(1.0-lit)*(1.0-exp(-shine));
fragColor=vec4(sqrt(clamp(lit,0.0,1.0)),1.0);
}
@@ -0,0 +1,12 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
#include <minecraft:dynamictransforms.glsl>
layout(location=0) in vec3 Position;
layout(location=1) in vec4 Color;
layout(location=2) in vec2 UV0;
layout(location=3) in vec2 UV3;
layout(location=0) out vec3 relativePosition;
layout(location=1) out vec2 blockUv;
layout(location=2) out vec2 maskUv;
layout(location=3) out vec4 tint;
void main(){gl_Position=ModelViewMat*vec4(Position,1.0);relativePosition=Position+ModelOffset;blockUv=UV0;maskUv=UV3;tint=Color;}
@@ -0,0 +1,34 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
#include <sanctuary:ssgi.glsl>
layout(location=0) in vec2 texCoord;
layout(location=0) out vec4 fragColor;
void main(){
float depth=texture(WorldDepth,texCoord).r;
if(depth<=0.0)discard;
vec3 p=giPosition(texCoord,depth),normal=giNormal(texCoord,p);
vec2 size=vec2(textureSize(IndirectLight,0)),coord=texCoord*size-0.5,base=floor(coord),f=fract(coord);
vec3 bounce=vec3(0.0);float total=0.0;
for(int y=0;y<2;y++)for(int x=0;x<2;x++){
vec2 uv=(base+vec2(x,y)+0.5)/size;
vec4 sampleLight=texture(IndirectLight,uv);
if(sampleLight.a<=0.0)continue;
vec2 surfaceUV=giTexel(uv);vec3 q=giPosition(surfaceUV,texture(WorldDepth,surfaceUV).r);
float plane=abs(dot(q-p,normal));
// Bilateral reconstruction never blends a wall's light into the foreground silhouette.
if(plane>0.06||length(q-p)>0.5)continue;
float weight=(x==0?1.0-f.x:f.x)*(y==0?1.0-f.y:f.y)*(1.0-smoothstep(0.01,0.06,plane));
bounce+=sampleLight.rgb*weight;total+=weight;
}
if(total<=0.0001)discard;
if(max(bounce.r,max(bounce.g,bounce.b))<=0.0)discard;
vec3 scene=texture(SceneColor,texCoord).rgb;
vec3 linearScene=scene*scene;
// Artistic single-bounce gain: the old gamma-space screen blend hid almost all
// of the transport on bright blocks. Compose in approximate linear light and
// encode once; a soft shoulder keeps strong bounce below channel clipping.
vec3 irradiance=bounce/total*(GISettings.x*96.0);
vec3 reflected=linearScene*irradiance;
vec3 lit=linearScene+(1.0-linearScene)*(1.0-exp(-reflected));
fragColor=vec4(sqrt(clamp(lit,0.0,1.0)),0.0);
}
@@ -0,0 +1,34 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
#include <sanctuary:ssgi.glsl>
layout(std140) uniform GIBlur { vec4 Direction; };
layout(location=0) in vec2 texCoord;
layout(location=0) out vec4 fragColor;
void main(){
vec4 center=texture(IndirectLight,texCoord);fragColor=center;
if(center.a<=0.0)return;
vec2 uv=giTexel(texCoord);float depth=texture(WorldDepth,uv).r;
if(depth<=0.0)return;
vec3 p=giPosition(uv,depth),n=giNormal(uv,p);
if(length(n)<.9)return;
vec2 pixel=Direction.xy/vec2(textureSize(IndirectLight,0));
vec2 nearUV=giTexel(texCoord+pixel);
float nearDepth=texture(WorldDepth,nearUV).r;
float footprint=nearDepth>0.0?length(giPosition(nearUV,nearDepth)-p):.12;
// Approx. 0.36 block support, capped at 24 half-resolution pixels at close range.
float stride=clamp(.12/max(footprint,.001),1.0,8.0);
vec3 total=center.rgb;float weight=1.0;
for(int i=-3;i<=3;i++){
if(i==0)continue;
vec2 tap=texCoord+pixel*(float(i)*stride);
if(any(lessThan(tap,vec2(0)))||any(greaterThan(tap,vec2(1))))continue;
vec4 light=texture(IndirectLight,tap);if(light.a<=0.0)continue;
vec2 surface=giTexel(tap);float d=texture(WorldDepth,surface).r;if(d<=0.0)continue;
vec3 q=giPosition(surface,d);float plane=abs(dot(q-p,n));
// Only blur the indirect light on the same receiving plane, never scene textures or silhouettes.
if(plane>.035||length(q-p)>.7)continue;
float w=exp(-float(i*i)*.32)*(1.0-smoothstep(.01,.035,plane));
total+=light.rgb*w;weight+=w;
}
fragColor=vec4(total/weight,center.a);
}
@@ -0,0 +1,51 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
#include <sanctuary:ssgi.glsl>
layout(location=0) in vec2 texCoord;
layout(location=0) out vec4 fragColor;
void main(){
fragColor=vec4(0.0);
vec2 uv=giTexel(texCoord);float depth=texture(WorldDepth,uv).r;
if(depth<=0.0)return;
vec3 p=giPosition(uv,depth);float distance=length(p);
if(distance>=GISettings.w)return;
vec3 n=giNormal(uv,p);
if(length(n)<0.9)return;
vec3 tangent=normalize(cross(n,abs(n.y)<0.9?vec3(0,1,0):vec3(0,0,1)));
vec3 bitangent=cross(n,tangent),sum=vec3(0.0);
// Fixed hemisphere directions; no per-frame stochastic noise or temporal history.
for(int ray=0;ray<12;ray++){
float angle=6.2831853*(float(ray<8?ray:ray-8)+0.5)/float(ray<8?8:4);
float elevation=ray<8?0.4:0.85;
vec3 direction=n*elevation+(tangent*cos(angle)+bitangent*sin(angle))*sqrt(1.0-elevation*elevation);
vec3 start=p+n*0.045;
for(int step=1;step<=10;step++){
float travel=GISettings.z*float(step)/10.0;
vec3 probe=start+direction*travel;
vec4 clip=ViewProjection*vec4(probe,1.0);
if(clip.w<=0.0)break;
vec2 hitUV=clip.xy/clip.w*0.5+0.5;
if(any(lessThan(hitUV,vec2(0.002)))||any(greaterThan(hitUV,vec2(0.998))))break;
hitUV=giTexel(hitUV);float hitDepth=texture(WorldDepth,hitUV).r;
if(hitDepth<=0.0)continue;
vec3 hit=giPosition(hitUV,hitDepth);float behind=length(probe)-length(hit);
if(behind<=0.015)continue;
// First occluder terminates the ray even when too thick: never continue behind a wall.
vec3 delta=hit-p;float separation=length(delta);
if(behind<0.35&&separation>0.08&&separation<GISettings.z&&dot(delta,n)>0.03){
vec3 hitNormal=giNormal(hitUV,hit);
float facing=max(0.0,dot(hitNormal,-normalize(delta)));
float edge=smoothstep(0.0,0.06,min(min(hitUV.x,hitUV.y),min(1.0-hitUV.x,1.0-hitUV.y)));
vec3 color=texture(SceneColor,hitUV).rgb;
float falloff=1.0-separation/GISettings.z;
// Vanilla already supplies neutral ambient light. Transport the chromatic
// remainder instead of re-injecting bright grey ground into every nearby face.
vec3 linear=color*color;
vec3 chroma=linear-vec3(min(linear.r,min(linear.g,linear.b)));
sum+=chroma*facing*falloff*edge*giFog(hit);
}
break;
}
}
fragColor=vec4(sum/12.0*giFog(p)*(1.0-smoothstep(GISettings.w*0.75,GISettings.w,distance)),distance);
}
@@ -0,0 +1,18 @@
uniform sampler2D WorldDepth;
layout(std140) uniform BloomScene {
mat4 InverseViewProjection;
vec4 BloomParams;
vec4 SunDirection;
vec4 MoonDirection;
vec4 FogDistance;
vec4 FogInfo;
};
vec3 bloomPosition(vec2 uv,float depth) {
vec4 p=InverseViewProjection*vec4(uv*2.0-1.0,BloomParams.y>0.5?depth:depth*2.0-1.0,1.0);
return p.xyz/p.w;
}
float bloomFog(vec3 position) {
float a=clamp((max(length(position.xz),abs(position.y))-FogDistance.x)/max(.001,FogDistance.y-FogDistance.x),0.0,1.0);
float b=clamp((length(position)-FogDistance.z)/max(.001,FogDistance.w-FogDistance.z),0.0,1.0);
return 1.0-max(a,b)*FogInfo.x;
}
@@ -0,0 +1,36 @@
uniform sampler2D WorldDepth;
uniform sampler2D SceneColor;
uniform sampler2D IndirectLight;
layout(std140) uniform GIParams {
mat4 InverseViewProjection;
mat4 ViewProjection;
vec4 GISettings; // strength, zero-to-one clip, local radius, view range
vec4 GIFog;
vec4 GIEnvironment;
};
vec3 giPosition(vec2 uv,float depth){
vec4 p=InverseViewProjection*vec4(uv*2.0-1.0,GISettings.y>0.5?depth:depth*2.0-1.0,1.0);
return p.xyz/p.w;
}
vec2 giTexel(vec2 uv){vec2 size=vec2(textureSize(WorldDepth,0));return (floor(uv*size)+0.5)/size;}
vec3 giNormal(vec2 uv,vec3 p){
vec2 pixel=1.0/vec2(textureSize(WorldDepth,0));
vec3 l=giPosition(uv-vec2(pixel.x,0),texture(WorldDepth,uv-vec2(pixel.x,0)).r);
vec3 r=giPosition(uv+vec2(pixel.x,0),texture(WorldDepth,uv+vec2(pixel.x,0)).r);
vec3 d=giPosition(uv-vec2(0,pixel.y),texture(WorldDepth,uv-vec2(0,pixel.y)).r);
vec3 u=giPosition(uv+vec2(0,pixel.y),texture(WorldDepth,uv+vec2(0,pixel.y)).r);
vec3 dx=length(r-p)<length(p-l)?r-p:p-l;
vec3 dy=length(u-p)<length(p-d)?u-p:p-d;
vec3 n=cross(dx,dy);n/=max(length(n),0.00001);
// Keep voxel planes exact: tiny depth-reconstruction errors must not rotate an entire ray fan.
vec3 a=abs(n);
if(max(a.x,max(a.y,a.z))>.995){
n=a.x>a.y&&a.x>a.z?vec3(sign(n.x),0,0):a.y>a.z?vec3(0,sign(n.y),0):vec3(0,0,sign(n.z));
}
return dot(n,-p)<0.0?-n:n;
}
float giFog(vec3 p){
float a=clamp((max(length(p.xz),abs(p.y))-GIFog.x)/max(GIFog.y-GIFog.x,0.001),0.0,1.0);
float b=clamp((length(p)-GIFog.z)/max(GIFog.w-GIFog.z,0.001),0.0,1.0);
return 1.0-max(a,b)*GIEnvironment.x;
}
+1 -1
View File
@@ -1,6 +1,6 @@
name = "Sanctuary"
author = "KOKA99CAB"
version = "beta.128"
version = "beta.134"
pack-format = "packwiz:1.1.0"
[index]