Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6502883cc4 | ||
|
|
a4e96c0d99 | ||
|
|
7c63865157 | ||
|
|
00be1eb0d0 | ||
|
|
adde24aa7e |
@@ -20,13 +20,14 @@ La [vision complète](docs/vision.md) conserve les intentions ; le
|
||||
L’économie, les claims et les dimensions décrits dans la vision restent
|
||||
à implémenter.
|
||||
|
||||
La version **beta.126** ajoute le rubis et le saphir, leurs minerais et leurs
|
||||
blocs, rangés après l'émeraude. Leur génération est configurable et limitée
|
||||
aux nouveaux chunks de Sanctuary. L'argile classique du Clay Workshop conserve
|
||||
les couleurs originales des modèles. La commande `/sanctuary discovery`
|
||||
enrichit les découvertes communes, globalement ou précisément, et actualise
|
||||
les recettes et catalogues liés. Le [ticket de livraison](docs/gems-beta126.md)
|
||||
décrit les règles, la compatibilité et les vérifications.
|
||||
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 [livraison précédente](docs/clay-workshop-integration-beta110.md) réunit déjà
|
||||
l’atelier, les œufs et la neige saisonnière dans l’instance **Sanctuary Beta**.
|
||||
Les [synchronisations antérieures](docs/git-sync-beta092.md) restent documentées.
|
||||
@@ -37,6 +38,22 @@ La [carte native beta.005](docs/atlas-beta005.md) prend le relais après
|
||||
l’étude de Xaero et des alternatives libres.
|
||||
|
||||
|
||||
## beta.128 — Ombres stables, lune et feuillage
|
||||
|
||||
Grille de réception stabilisée, contours nets des blocs, atténuation à midi,
|
||||
ombre lunaire légère et feuillage nuancé selon son épaisseur.
|
||||
[Contrat et vérifications](docs/shader-beta128.md) ·
|
||||
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.128/Sanctuary-beta.128.mrpack) ·
|
||||
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.128/Sanctuary-Test-beta.128.mrpack).
|
||||
|
||||
## beta.127 — Shader : ombres portées pixélisées
|
||||
|
||||
Ombres solaires natives, finesse 8/16/32 pixels par bloc et portée 16/32/64 blocs.
|
||||
Option désactivée par défaut, réglable sans rechargement des ressources.
|
||||
[Contrat et vérifications](docs/shader-beta127.md) ·
|
||||
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.127/Sanctuary-beta.127.mrpack) ·
|
||||
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.127/Sanctuary-Test-beta.127.mrpack).
|
||||
|
||||
## beta.126 — Gemmes, argile et découvertes
|
||||
|
||||
Rubis et saphir avec filons configurables, couleurs originales avec l'argile,
|
||||
|
||||
@@ -134,3 +134,19 @@ le template reproduit exactement ses ressources exportables.
|
||||
| `Sanctuary-beta.126.mrpack` | `716221ce24549781ab91f5437ad2310497b198a4b61c43c40c67a27984365ae5` |
|
||||
| `Sanctuary-Test-beta.126.mrpack` | `bbf22d7dd72e6746538b4ec28686f699225d4a59372304e5db42140a80e88bd1` |
|
||||
| `sanctuary-beta.126.jar` | `85dbe1b41d98751029bb62e87d259522f8609758896c797b1e02fa7656ec0342` |
|
||||
|
||||
## Publication et synchronisation
|
||||
|
||||
[Release beta.126](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.126)
|
||||
publiée depuis `99860af312a1737d938812f3515fcc4acb4291b2` ; artefacts
|
||||
immuables retéléchargés et vérifiés. Canal packwiz : `31749b95d21ce5bced6b9e5759499da6f8a67e6e`.
|
||||
Deux synchronisations isolées, puis deux synchronisations de l'instance existante
|
||||
**Sanctuary Beta** ont réussi. Un seul JAR Sanctuary beta.126, correspondant
|
||||
à l'empreinte ci-dessus. Les **923 fichiers personnels** suivis sont conservés,
|
||||
sauvegardes restées fermées ; copie préalable dans
|
||||
`sanctuary-backups/before-beta.126/`.
|
||||
|
||||
Packs normal/Test et template copiés et vérifiés dans
|
||||
`sanctuary-beta/build/`. Reçus locaux : `build/gems126-artifact.json`,
|
||||
`build/gems126-isolated.json`, `build/gems126-prism.json` et
|
||||
`build/gems126-publication.log`.
|
||||
|
||||
@@ -14,6 +14,40 @@ 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.128 — stabilité des ombres, lune et feuillage
|
||||
|
||||
Ombres des blocs nettes et stabilisées, atténuation à midi, lune légère et
|
||||
feuillage nuancé selon son épaisseur. [Contrat et vérifications](shader-beta128.md).
|
||||
[Release beta.128](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.128)
|
||||
publiée ; canal `eba01726ee132a6e548d129fa7ee04f24c880917`. 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.128/`.
|
||||
Packs normal/Test et template copiés et vérifiés dans `sanctuary-beta/build/`.
|
||||
|
||||
## beta.127 — shader et ombres pixélisées
|
||||
|
||||
Ombres solaires optionnelles intégrées à Sanctuary · Vanilla Light, avec finesse
|
||||
et portée réglables. Socle beta.126 conservé. [Contrat et vérifications](shader-beta127.md).
|
||||
[Release beta.127](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.127)
|
||||
publiée ; canal `7fd24a43e01ee497a23311e3c78e1dd3dddd877a`. 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.127/`.
|
||||
Packs normal/Test et template copiés et vérifiés dans `sanctuary-beta/build/`.
|
||||
|
||||
## beta.126 — gemmes, argile et découvertes
|
||||
|
||||
Rubis et saphir, nouvelles textures d'items, argile conservant les couleurs
|
||||
originales et commandes de découverte communes aux recettes et catalogues.
|
||||
[Contrat et vérifications](gems-beta126.md).
|
||||
[Release beta.126](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.126)
|
||||
publiée ; canal `31749b95d21ce5bced6b9e5759499da6f8a67e6e`. Deux synchronisations
|
||||
isolées puis deux dans la même instance **Sanctuary Beta** réussies.
|
||||
Les 923 fichiers personnels et réglages suivis sont inchangés ; sauvegardes
|
||||
restées fermées. Copie préalable dans `sanctuary-backups/before-beta.126/`.
|
||||
Packs normal/Test et template copiés et vérifiés dans `sanctuary-beta/build/`.
|
||||
|
||||
## beta.125 — placement, éclats et animaux sculptés
|
||||
|
||||
Coins et trois hauteurs, particules de voxels et espèces découvertes au Clay
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# Préparation locale des ombres portées pixélisées (ancien numéro beta.122)
|
||||
|
||||
Ticket du 17 septembre 2026, branche locale `codex/pixel-shadows-beta122`.
|
||||
Cette préparation n'a jamais été publiée sous le numéro beta.122, déjà utilisé
|
||||
par une autre livraison. Elle est intégrée au socle beta.126 et livrée en
|
||||
[beta.127](shader-beta127.md). Les chemins et mesures ci-dessous décrivent
|
||||
les essais historiques dans le dossier de préparation.
|
||||
Demande : ajouter aux réglages de Sanctuary · Vanilla Light des ombres portées
|
||||
pixélisées, avec une finesse et une distance réglables.
|
||||
|
||||
## Utilisation
|
||||
|
||||
Dans **Options → Shaders**, activer **Sanctuary · Vanilla Light**, puis
|
||||
**Ombres portées pixélisées**. L'option est désactivée par défaut pour conserver
|
||||
le coût et le rendu des installations existantes. Les choix sont :
|
||||
|
||||
- finesse de **8, 16 ou 32 pixels par bloc**, défaut 16 ;
|
||||
- distance de **16, 32 ou 64 blocs**, défaut 32.
|
||||
|
||||
Ces préférences sont conservées dans `config/sanctuary-shaders.json`, avec les
|
||||
anciens champs de couleur. Leurs valeurs numériques sont ramenées aux choix
|
||||
admis au chargement. Les libellés et explications sont disponibles en FR/EN.
|
||||
Les réglages s'appliquent sans recharger les ressources.
|
||||
|
||||
## Rendu et périmètre
|
||||
|
||||
Une carte de profondeur rend les formes d'occultation des blocs solides chargés
|
||||
autour de la caméra, indépendamment de leur présence à l'écran. Les dalles et
|
||||
escaliers conservent leurs formes. Le cache est invalidé par les changements de
|
||||
blocs, les paquets de chunks et les reconstructions du moteur natif.
|
||||
La direction solaire vient du ciel natif extrait par Minecraft, et respecte
|
||||
ainsi l'angle solaire de Sanctuary. L'effet s'atténue sous la pluie, à l'horizon,
|
||||
dans la brume et à la limite de sa portée. Il s'arrête la nuit, dans les dimensions
|
||||
sans soleil, dans les fluides et lorsqu'un moteur externe suspend Vanilla Light.
|
||||
|
||||
La profondeur du monde est lue directement par le GPU après le rendu du monde,
|
||||
avant la main et l'interface. L'inverse de la projection réelle inclut les effets
|
||||
de caméra. Les points recevant l'ombre sont arrondis sur une grille ancrée dans
|
||||
le monde ; le plan de la face est conservé pour limiter les auto-ombres.
|
||||
La carte solaire utilise également une origine alignée sur ses texels.
|
||||
Le HUD, la main, les étoiles et les captures panoramiques techniques gardent leur
|
||||
chemin de rendu. Aucun niveau de lumière de gameplay, règle serveur, sauvegarde
|
||||
ou générateur n'est changé. Aucune dépendance de rendu n'est ajoutée.
|
||||
|
||||
Cette première version couvre les **formes d'occultation des blocs** : les entités
|
||||
conservent leurs ombres natives ; les feuillages ajourés, modèles non occultants,
|
||||
block entities animés, eau et verre ne projettent pas de nouvelles silhouettes.
|
||||
Les transparences et particules déjà composées peuvent recevoir l'assombrissement
|
||||
du terrain derrière elles. Il n'y a pas d'ombres de lune ou de torches, ni de
|
||||
séparation de l'éclairage local émissif dans cette passe d'assombrissement.
|
||||
|
||||
## Coût et cycle de vie
|
||||
|
||||
Les maillages sont mis en cache par section de 16³, avec au plus quatre sections
|
||||
commencées par image et un budget souple de 3 ms pour leur construction. Les
|
||||
ombres apparaissent progressivement pendant la préparation après activation ou
|
||||
déplacement. Le volume des obstacles part de la portée choisie avec une marge de huit blocs,
|
||||
puis s’étend vers le soleil sur deux fois cette portée, arrondi aux sections.
|
||||
Les obstacles au-delà de ce volume ou dans des chunks non chargés ne sont pas
|
||||
représentés. Les mises à jour de lumière seule ne reconstruisent pas les formes.
|
||||
Le cache de sommets est plafonné à 96 Mio et omet les formes pathologiques qui
|
||||
dépassent ses limites. Les textures de profondeur font entre 512 et 4 096 pixels
|
||||
de côté selon finesse et portée. Le coût exact dépend de la scène et du GPU.
|
||||
|
||||
Les ressources sont libérées à la désactivation, au changement de monde, à la
|
||||
reconstruction des ressources et à l'arrêt du client. Cette livraison est locale :
|
||||
aucun avancement du canal packwiz ni installation personnelle ne fait partie du
|
||||
ticket.
|
||||
|
||||
## Vérification
|
||||
|
||||
Validation native sur **Minecraft 26.3, Java 25, macOS / Apple M1, OpenGL 4.1**,
|
||||
dans un nouveau monde plat de développement, graine **122** :
|
||||
|
||||
- Comparaison GPU activation/désactivation sur une plateforme blanche avec
|
||||
pilier, dalle et escalier : **920 échantillons assombris**, 63 412 inchangés,
|
||||
aucun éclairci sur les 64 332 échantillons de sol retenus.
|
||||
- Pilier entièrement hors du frustum : **42 048 échantillons** restent dans
|
||||
son ombre. Le retrait du pilier restitue la lumière sur **42 048 / 42 048**
|
||||
échantillons et préserve les 23 616 échantillons déjà éclairés. Sa repose
|
||||
restitue la même ombre, sans désactiver le cache entre ces manipulations.
|
||||
- Les trois finesses et les trois portées sont appliquées en direct ; les cartes
|
||||
GPU sont redimensionnées aux dimensions attendues. Désactiver l'option libère
|
||||
la texture et ne soumet plus de passe d'ombres.
|
||||
- À minuit, activer ou désactiver l'option donne la même image et aucune passe
|
||||
solaire n'est soumise. Au retour du jour, le rendu reprend.
|
||||
- Le rechargement des ressources restitue la même image. Les préférences
|
||||
persistent à la relecture du fichier et leurs valeurs extrêmes sont bornées.
|
||||
- Menus FR/EN inspectés en capture ; les libellés tiennent dans les contrôles
|
||||
natifs à la taille de test. Les vues de finesse 8/16/32 sont conservées.
|
||||
|
||||
```sh
|
||||
./gradlew :sanctuary:runClientGameTest \
|
||||
-PsanctuaryClientTests=true -PsanctuaryPixelShadows122ClientTests=true \
|
||||
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true
|
||||
```
|
||||
|
||||
Succès en **1 min 17 s**, marqueur `PIXEL_SHADOWS122_CLIENT_PASS` dans
|
||||
`build/pixel-shadows122-client.log`. Captures inspectées dans
|
||||
`build/pixel-shadows122-evidence/final/`. Les pixels sont relus uniquement dans
|
||||
le test ; le code de rendu de production n'effectue aucune lecture CPU de GPU.
|
||||
Les premiers démarrages ont permis de corriger les identifiants de pipelines,
|
||||
la directive GLSL `#include` attendue par 26.3 et deux libellés répétés du menu.
|
||||
|
||||
La commande de livraison est :
|
||||
|
||||
```sh
|
||||
./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest
|
||||
```
|
||||
|
||||
Le serveur GameTest dédié est exclu, conformément au refus de son EULA déjà
|
||||
consigné dans les livraisons précédentes. Le test graphique utilise le serveur
|
||||
intégré de son monde neuf. Aucun monde personnel n'est ouvert.
|
||||
La construction finale **réussit en 2 min 18 s** (126 tâches), journal
|
||||
`build/pixel-shadows122-build-final.log`. Le contrôle des archives confirme
|
||||
**868 sources Java** et **1 869 classes** identiques aux sorties construites,
|
||||
les ressources GLSL/FR/EN exactes dans le JAR et le template, et le même JAR
|
||||
dans les profils packwiz normal et Test. Aucune fixture cliente n'est embarquée.
|
||||
Reçu : `build/pixel-shadows122-artifact.json`.
|
||||
|
||||
Artefacts locaux : `mods/sanctuary/build/libs/sanctuary-beta.122.jar`,
|
||||
`build/packwiz/`, `build/packwiz-test/` et `build/Sanctuary-Template-beta.122.zip`.
|
||||
SHA-256 du JAR :
|
||||
`66987681cede1d2adc1dda449078e8fa85310eb2c3fecd7a905d772ce75739c9`.
|
||||
|
||||
Pas de mesure de FPS, d'essai Windows/Vulkan, de pack Iris ni de silhouettes
|
||||
animées revendiqués. Le shader reste un assombrissement graphique appliqué au
|
||||
rendu opaque et déjà composé, avec les limites de transparence décrites plus haut.
|
||||
@@ -0,0 +1,88 @@
|
||||
# SHADER-127 — intégration des ombres portées pixélisées
|
||||
|
||||
Demande du 17 septembre 2026 : livrer le shader dans **beta.127**.
|
||||
Branche `codex/shader-beta127`, socle publié beta.126, Minecraft 26.3 / Java 25.
|
||||
|
||||
La préparation locale [des ombres pixélisées](pixel-shadows-source122.md) est
|
||||
intégrée sans écraser les changements non publiés du dossier d'origine.
|
||||
Les gemmes, dernières textures de rubis/saphir, l'argile spéciale et les
|
||||
commandes de découverte de beta.126 sont conservées.
|
||||
|
||||
## Utilisation
|
||||
|
||||
**Options → Shaders → Sanctuary · Vanilla Light → Ombres portées pixélisées**.
|
||||
L'option est désactivée par défaut ; son activation reste un choix du joueur.
|
||||
Finesse : 8/16/32 pixels par bloc ; portée : 16/32/64 blocs. Défauts : 16 et 32.
|
||||
Les paramètres FR/EN s'appliquent immédiatement et persistent dans
|
||||
`config/sanctuary-shaders.json`. La mise à jour préserve les réglages personnels.
|
||||
Le rendu est natif au mod : aucun pack Iris, téléchargement supplémentaire ou
|
||||
nouvelle dépendance de rendu.
|
||||
|
||||
## Périmètre
|
||||
|
||||
Ombres solaires des formes d'occultation des blocs chargés, incluant dalles,
|
||||
escaliers et obstacles hors écran. Carte de profondeur GPU, grille ancrée dans
|
||||
le monde, atténuation selon soleil, pluie et brume. L'effet s'arrête la nuit,
|
||||
dans les dimensions sans soleil, dans les fluides ou avec un moteur externe.
|
||||
Le HUD et la main ne passent pas dans cet effet. La lumière de gameplay,
|
||||
les règles serveur, les sauvegardes et la génération ne sont pas modifiées.
|
||||
|
||||
La préparation limite le cache à 96 Mio et sa construction à quatre sections
|
||||
par image dans un budget souple de 3 ms. L'intégration supprime seulement une
|
||||
invalidation de bord exécutée deux fois à l'identique dans le cache de terrain.
|
||||
Les formes non occultantes, feuillages ajourés, eau/verre et entités animées ne
|
||||
projettent pas de nouvelles silhouettes. Les transparences déjà composées
|
||||
peuvent recevoir l'assombrissement du terrain situé derrière elles. Pas
|
||||
d'ombres de torches/lune ni de mesure de FPS revendiquée.
|
||||
|
||||
## Vérifications et livraison
|
||||
|
||||
Client natif Minecraft 26.3 / Java 25 sur macOS, monde neuf plat, graine 122 :
|
||||
`PixelShadows122ClientChecks` réussit sur la beta.127 en **1 min 26 s**.
|
||||
Le nom de cette fixture historique est conservé pour suivre la même scène.
|
||||
|
||||
- Activation : 920 échantillons de sol assombris, 63 412 inchangés, aucun éclairci.
|
||||
- Pilier hors écran : 42 048 échantillons restent ombrés ; le retrait du pilier
|
||||
restitue la lumière sur les 42 048 et conserve les 23 616 déjà éclairés.
|
||||
La repose rétablit la même ombre sans désactiver le cache.
|
||||
- Trois finesses et trois portées, redimensionnement GPU, désactivation/libération,
|
||||
arrêt nocturne, reprise diurne et rechargement des ressources vérifiés.
|
||||
- Préférences persistantes et bornées ; captures FR/EN inspectées, sans texte
|
||||
tronqué. La scène pilier/dalle/escalier est également inspectée visuellement.
|
||||
|
||||
Logs : `build/shader127-client.log`. Captures :
|
||||
`mods/sanctuary/build/run/clientGameTest/screenshots/`.
|
||||
Le serveur GameTest dédié reste exclu conformément au refus antérieur de son
|
||||
EULA. Aucun monde personnel n'est ouvert. Aucun essai Windows/Vulkan ou FPS
|
||||
n'est revendiqué ; la validation graphique est celle du client natif local.
|
||||
|
||||
|
||||
Build `./gradlew check build assemblePack assembleTestPack
|
||||
-x :sanctuary:runGameTest` réussi en **2 min 25 s**, 126 tâches. Les sources
|
||||
Java dans les archives correspondent aux sources du ticket. La comparaison
|
||||
avec le JAR beta.126 ne trouve que **21 entrées de production modifiées**,
|
||||
toutes liées au shader, à son initialisation, ses mixins et ses libellés.
|
||||
Les règles de jeu, données, textures de gemmes, sculptures et découvertes
|
||||
sont identiques. Normal et Test contiennent le même JAR ; le template exporte
|
||||
exactement les ressources du mod. Aucune fixture de test ni monde embarqué.
|
||||
|
||||
| Artefact | SHA-256 |
|
||||
| --- | --- |
|
||||
| `Sanctuary-beta.127.mrpack` | `23fa6ca0c5d1f1033e13e5370d669491b398d5a99e13f8bd1e983f998a24a20a` |
|
||||
| `Sanctuary-Test-beta.127.mrpack` | `f185f3a743ba23cd6c024d1c1e46587706bf4f3edb5e14d7e5dee522a1ebf2c2` |
|
||||
| `sanctuary-beta.127.jar` | `3ef3fab67f4f703864a1acc9ae2395c636d206f3128d3399140eaf045a46d5f3` |
|
||||
|
||||
## Publication et synchronisation
|
||||
|
||||
[Release beta.127](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.127)
|
||||
publiée depuis `00be1eb0d0bfe042442d90255385033a7765092f` ; artefacts immuables retéléchargés
|
||||
et vérifiés. Canal packwiz : `7fd24a43e01ee497a23311e3c78e1dd3dddd877a`.
|
||||
Deux synchronisations isolées puis deux dans l'instance existante
|
||||
**Sanctuary Beta** ont réussi. Les **923 fichiers personnels** suivis,
|
||||
y compris les préférences de shaders, sont conservés. Aucun monde personnel
|
||||
ouvert ; copie préalable dans `sanctuary-backups/before-beta.127/`.
|
||||
|
||||
Les packs normal/Test et le template sont aussi copiés et vérifiés dans
|
||||
`sanctuary-beta/build/`. Reçus locaux : `build/shader127-artifact.json`,
|
||||
`build/shader127-isolated.json`, `build/shader127-prism.json` et journal de
|
||||
publication `build/shader127-publication.log`.
|
||||
@@ -0,0 +1,106 @@
|
||||
# SHADER-128 — stabilité, lune et feuillage
|
||||
|
||||
Demandes du 17 septembre 2026 : réduire le jitter des bords et à midi, conserver
|
||||
des ombres de blocs nettes et un feuillage nuancé, ajouter une ombre lunaire
|
||||
fine et une transmission
|
||||
irrégulière du feuillage selon son épaisseur. Branche `codex/moon-foliage-beta128`,
|
||||
socle publié beta.127, Minecraft 26.3 / Java 25.
|
||||
|
||||
## Résultat
|
||||
|
||||
Les faces de voxels reçoivent l'ombre sur une grille fixe ; leur plan est
|
||||
stabilisé au 1/16 de bloc et les faces inclinées conservent leur normale. La
|
||||
carte de profondeur utilise un ancrage par 16 blocs avec alignement de ses
|
||||
texels. **Les ombres des blocs restent nettes** : le créateur a écarté leur
|
||||
adoucissement après l'essai visuel. Le filtrage est réservé au feuillage.
|
||||
Le soleil continue de se déplacer : ce mouvement naturel peut toujours faire
|
||||
avancer une ombre d'une case. L'objectif est de supprimer les oscillations
|
||||
liées à la caméra, pas de figer le temps.
|
||||
|
||||
Entre une élévation solaire de 0,92 et le zénith, l'intensité diminue doucement
|
||||
jusqu'à 25 % de sa valeur habituelle : alpha maximal 0,095 au lieu de 0,38.
|
||||
La lune réutilise la même carte et son angle natif ; alpha maximal 0,10 selon
|
||||
sa phase, nul à la nouvelle lune. Horizon, pluie, portée et brume atténuent
|
||||
les deux lumières. Pas de seconde passe de carte lunaire simultanée.
|
||||
|
||||
Les blocs du tag vanilla `leaves` projettent une couverture probabiliste fixe
|
||||
sur une grille de quart de bloc. Les faces internes restent présentes : plus
|
||||
le rayon traverse de couches, moins il a de chances de passer. Il s'agit d'une
|
||||
approximation statistique de l'épaisseur, pas d'un comptage des feuilles ni
|
||||
d'une lecture des trous de leur texture. Quatre échantillons bilinéaires sur
|
||||
un quart de bloc mélangent cette couverture
|
||||
en nuances, évaluées sur la grille pixélisée. Un masque R8 distingue feuilles
|
||||
et blocs dans la même passe de profondeur, pour garder les silhouettes opaques
|
||||
nettes. Les comparaisons suivent le plan récepteur pour éviter l'auto-ombrage
|
||||
sur les surfaces obliques. Le motif ne dépend ni du temps ni de la caméra et
|
||||
ne demande aucune lecture GPU côté CPU en production.
|
||||
Les budgets du cache restent 96 Mio, quatre sections et 3 ms souples par image.
|
||||
Le feuillage ajoute des faces ; son filtrage utilise seize comparaisons de
|
||||
profondeur et, selon le cas, autant de lectures du masque. Les ombres opaques
|
||||
pleines utilisent le chemin court. Le masque prend 4 Mio à la définition
|
||||
par défaut, 16 Mio au maximum ; aucune promesse de FPS n'est faite.
|
||||
|
||||
Réglages existants et libellés FR/EN conservés/actualisés. Les ombres restent
|
||||
optionnelles et désactivées par défaut. Règles, éclairage de gameplay, mondes
|
||||
et génération inchangés. Les limitations de beta.127 concernant les entités,
|
||||
fluides, verre et autres formes non occultantes subsistent.
|
||||
|
||||
## Vérifications
|
||||
|
||||
`PixelShadows122ClientChecks`, fixture historique étendue, réussit en **1 min
|
||||
44 s** dans un monde neuf plat, graine 122, client natif macOS/OpenGL.
|
||||
|
||||
- Soleil : 920 échantillons de sol ombrés et 63 412 inchangés ; retrait/repose
|
||||
d'un pilier hors écran restitue puis rétablit son ombre.
|
||||
- Lune : ombre localisée mesurée ; arrêt des passes à la nouvelle lune.
|
||||
- Midi : ombre atténuée et **zéro échantillon de transition adoucie sur 65 664**
|
||||
dans la scène de contrôle du contour opaque.
|
||||
- Caméra : petits déplacements autour de l'ancien ancrage entier et du nouvel
|
||||
ancrage par 16 blocs, retour au point initial, petites rotations en vue
|
||||
oblique, instants avant/après midi et passage de 5999 à 6001 ticks.
|
||||
Comparaisons d'images avec tolérance de trois niveaux par canal et moins
|
||||
de 1 % d'échantillons changés pour ces petits mouvements.
|
||||
- Vue oblique : 19 637 échantillons ombrés, 46 027 inchangés sur le sol blanc.
|
||||
- Feuillage : assombrissement moyen mesuré d'environ **18,6 %** pour une couche,
|
||||
**31,7 %** pour quatre, contre **36,8 %** sous une couverture solide.
|
||||
Motif fixe dans le temps ; modification des couches invalide le cache actif.
|
||||
- Trois finesses/trois portées, désactivation/libération GPU, préférences
|
||||
persistantes, rechargement des ressources, textes FR/EN et captures inspectés.
|
||||
|
||||
Commande native : `./gradlew :sanctuary:runClientGameTest
|
||||
-PsanctuaryClientTests=true -PsanctuaryPixelShadows122ClientTests=true
|
||||
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true` sous Java 25.
|
||||
Logs : `build/shader128-client.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.
|
||||
Les sources archivées correspondent au ticket. **13 entrées de production**
|
||||
diffèrent de beta.127, limitées aux classes du shader, GLSL et libellés FR/EN.
|
||||
Gemmes, sculptures, découvertes, données et règles de jeu restent identiques.
|
||||
Normal et Test embarquent le même JAR ; le template correspond exactement aux
|
||||
ressources exportables. Aucun monde ni fixture de test embarqué.
|
||||
Le serveur GameTest dédié reste exclu conformément au refus antérieur de son
|
||||
EULA. Aucun monde personnel utilisé. Aucun essai Windows/Vulkan revendiqué.
|
||||
|
||||
## Artefacts vérifiés
|
||||
|
||||
| Artefact | SHA-256 |
|
||||
| --- | --- |
|
||||
| `Sanctuary-beta.128.mrpack` | `84125b18366afde52c8cba4bc7007257b1c3d8b79d413338abe64d0e0ae8feaf` |
|
||||
| `Sanctuary-Test-beta.128.mrpack` | `6bfb10d8d63875bfb84e813d4f0a9faf5db30902cc0400deda0993c08b86e597` |
|
||||
| `sanctuary-beta.128.jar` | `c54cbae7efaa7059c8fd4d28801087983379958fe34eedff51682d106d649c6a` |
|
||||
|
||||
## Publication et synchronisation
|
||||
|
||||
[Release beta.128](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.128)
|
||||
publiée depuis `a4e96c0d99c976231d7ca92b1db6bdb6aa7083c6` ; les artefacts immuables ont été
|
||||
retéléchargés et vérifiés. Canal packwiz : `eba01726ee132a6e548d129fa7ee04f24c880917`.
|
||||
Deux synchronisations isolées puis deux dans l'instance existante
|
||||
**Sanctuary Beta** ont réussi. Les **923 fichiers personnels** suivis,
|
||||
y compris les réglages de shaders, sont conservés. Aucun monde personnel
|
||||
ouvert ; copie préalable dans `sanctuary-backups/before-beta.128/`.
|
||||
Packs normal/Test et template copiés et vérifiés dans `sanctuary-beta/build/`.
|
||||
|
||||
Reçus locaux : `build/shader128-artifact.json`, `build/shader128-isolated.json`,
|
||||
`build/shader128-prism.json` ; journal : `build/shader128-publication.log`.
|
||||
@@ -102,3 +102,8 @@ client natif : témoin `SHADER040_CLIENT_PASS` dans
|
||||
`build/operator040-client-final.log`. Captures dans
|
||||
`build/operator040-evidence/final/`, reçu dans `build/operator040-artifact.json`.
|
||||
Les contrôles ne revendiquent pas un essai avec Iris ou un autre moteur externe.
|
||||
|
||||
|
||||
Les ombres portées pixélisées sont livrées dans [beta.127](shader-beta127.md),
|
||||
intégrées sur la beta.126. Leur préparation locale portait un numéro beta.122
|
||||
non publié ; la release beta.122 existante n'est pas remplacée.
|
||||
|
||||
+2
-2
@@ -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.126
|
||||
pack_version=beta.126
|
||||
mod_version=beta.128
|
||||
pack_version=beta.128
|
||||
resource_pack_version=beta.121
|
||||
maven_group=fr.koka.sanctuary
|
||||
jei_version=30.32.0-sanctuary.3
|
||||
|
||||
@@ -93,6 +93,10 @@ tasks.named('runGameTest') {
|
||||
}
|
||||
|
||||
tasks.named('processGametestResources') {
|
||||
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') }
|
||||
|
||||
def gems126Client = providers.gradleProperty('sanctuaryGems126ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('sanctuaryGems126ClientTests', gems126Client)
|
||||
if (gems126Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Gems126ClientChecks') }
|
||||
|
||||
+634
@@ -0,0 +1,634 @@
|
||||
package fr.koka.sanctuary.client.shader;
|
||||
|
||||
import com.mojang.blaze3d.platform.InputConstants;
|
||||
import fr.koka.sanctuary.client.HelloWorldScreen;
|
||||
import fr.koka.sanctuary.client.IntroClient;
|
||||
import fr.koka.sanctuary.client.IntroSpawnFlash;
|
||||
import fr.koka.sanctuary.client.ProgressionClient;
|
||||
import fr.koka.sanctuary.gametest.Starter039TestSupport;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
|
||||
import net.fabricmc.loader.api.FabricLoader;
|
||||
import net.minecraft.client.CameraType;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.Screenshot;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
|
||||
import net.minecraft.client.input.KeyEvent;
|
||||
import net.minecraft.client.renderer.culling.Frustum;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.network.chat.contents.TranslatableContents;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.StairBlock;
|
||||
import net.minecraft.world.level.gamerules.GameRules;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
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 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);
|
||||
private static final BlockPos STAIR = new BlockPos(5, FLOOR + 3, 5);
|
||||
private static final int DARK_DELTA = 8;
|
||||
|
||||
@Override
|
||||
public void runTest(ClientGameTestContext c) {
|
||||
var path = FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");
|
||||
final byte[] originalPreferences;
|
||||
try {
|
||||
originalPreferences = Files.exists(path) ? Files.readAllBytes(path) : null;
|
||||
} catch (IOException e) {
|
||||
throw new AssertionError("Cannot back up shader preferences", e);
|
||||
}
|
||||
var originalOptions = c.computeOnClient(Options::capture);
|
||||
var onboarding = new AtomicBoolean(true);
|
||||
ClientTickEvents.END_CLIENT_TICK.register(m -> {
|
||||
if (onboarding.get() && m.gui.screen() instanceof HelloWorldScreen screen) {
|
||||
// Public test support works across packages; enter through the native widgets.
|
||||
IntroClient.setQuickTestIntro(true);
|
||||
if (!screen.starterChoices().isEmpty()) Starter039TestSupport.selectFirst(screen);
|
||||
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast)
|
||||
.filter(b -> b.getClass().getSimpleName().equals("ColorButton"))
|
||||
.findFirst().orElseThrow().onPress(enter());
|
||||
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast)
|
||||
.filter(b -> b.getMessage().getContents() instanceof TranslatableContents t
|
||||
&& t.getKey().equals("sanctuary.progression.enter"))
|
||||
.findFirst().orElseThrow().onPress(enter());
|
||||
onboarding.set(false);
|
||||
}
|
||||
});
|
||||
try {
|
||||
c.runOnClient(m -> {
|
||||
m.options.renderDistance().set(6);
|
||||
m.options.simulationDistance().set(5);
|
||||
m.options.bobView().set(false);
|
||||
m.options.pauseOnLostFocus = false;
|
||||
m.options.setCameraType(CameraType.FIRST_PERSON);
|
||||
checkPreferences();
|
||||
});
|
||||
try (var world = c.worldBuilder().adjustSettings(s -> {
|
||||
s.setName("PixelShadows122 " + UUID.randomUUID());
|
||||
s.setSeed("122");
|
||||
s.setAllowCommands(true);
|
||||
s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);
|
||||
s.setWorldType(s.getNormalPresetList().stream().filter(e -> e.preset() != null
|
||||
&& e.preset().getRegisteredName().equals("sanctuary_test:flat"))
|
||||
.findFirst().orElseThrow());
|
||||
}).create()) {
|
||||
world.getConnection().waitForChunksDownload(2400);
|
||||
c.waitFor(m -> m.player != null && ProgressionClient.active()
|
||||
&& m.gui.screen() == null && !IntroSpawnFlash.active(), 1200);
|
||||
c.getInput().pressKey(InputConstants.KEY_F1);
|
||||
var server = world.getServer();
|
||||
server.runOnServer(s -> {
|
||||
command(s, "sanctuary time vanilla");
|
||||
command(s, "sanctuary weather vanilla");
|
||||
command(s, "weather clear 100000");
|
||||
if (s.getGlobalGameRules().get(GameRules.ADVANCE_TIME))
|
||||
command(s, "gamerule " + GameRules.ADVANCE_TIME.id() + " false");
|
||||
check(!s.getGlobalGameRules().get(GameRules.ADVANCE_TIME), "Native daylight rule is frozen");
|
||||
command(s, "time set 3000");
|
||||
command(s, "gamemode spectator @a");
|
||||
if (s.getGlobalGameRules().get(GameRules.SPAWN_MOBS))
|
||||
command(s, "gamerule " + GameRules.SPAWN_MOBS.id() + " false");
|
||||
var level = s.overworld();
|
||||
var entities = new java.util.ArrayList<net.minecraft.world.entity.Entity>();
|
||||
level.getAllEntities().forEach(entities::add);
|
||||
entities.stream().filter(e -> !(e instanceof net.minecraft.server.level.ServerPlayer))
|
||||
.forEach(net.minecraft.world.entity.Entity::discard);
|
||||
// The elevated platform isolates the samples from grass, mobs and flat-world lighting.
|
||||
for (int x = -24; x <= 24; x++) for (int z = -24; z <= 24; z++) {
|
||||
level.setBlockAndUpdate(new BlockPos(x, FLOOR, z), Blocks.CONCRETE.white().defaultBlockState());
|
||||
}
|
||||
pillar(s, true);
|
||||
// Coloured casters cannot be mistaken for neutral white receiving ground.
|
||||
level.setBlockAndUpdate(SLAB, Blocks.RED_SANDSTONE_SLAB.defaultBlockState());
|
||||
level.setBlockAndUpdate(STAIR, Blocks.RED_SANDSTONE_STAIRS.defaultBlockState()
|
||||
.setValue(StairBlock.FACING, Direction.WEST));
|
||||
});
|
||||
camera(c, server, -2, FLOOR + 18, 1, 70);
|
||||
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.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);
|
||||
|
||||
// Keep VanillaLight enabled for both frames: changing its fog/grade would invalidate this oracle.
|
||||
shadows(c, false);
|
||||
var off = capture(c, "day-off");
|
||||
c.waitTicks(6);
|
||||
unchanged(off, capture(c, null), "Fixed camera/daylight OFF control");
|
||||
shadows(c, true);
|
||||
var on = capture(c, "day-on-pillar-slab-stair");
|
||||
var day = compare(off, on);
|
||||
localShadow(day, "Pillar/slab/stair daylight ground", .70);
|
||||
c.runOnClient(m -> {
|
||||
check(Float.compare(morning, m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle) == 0,
|
||||
"Native rule actually freezes the rendered sun angle");
|
||||
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}) {
|
||||
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}) {
|
||||
c.runOnClient(m -> VanillaLight.setShadowDistance(distance));
|
||||
activeFrames(c);
|
||||
c.runOnClient(m -> check(PixelShadows.shadowMap().width == PixelShadows.mapSize(distance, 16),
|
||||
"Live distance reallocates the correct depth map"));
|
||||
}
|
||||
shadows(c, false);
|
||||
unchanged(off, capture(c, "day-off-restored"), "Disabling removes the shadow without residual grading");
|
||||
|
||||
// Aim at the shadow's far end, with its entire caster outside the actual camera frustum.
|
||||
shadows(c, true);
|
||||
double shadowX = c.computeOnClient(m -> {
|
||||
var sun = PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle);
|
||||
check(sun.y > .2f && Math.abs(sun.x / sun.y) > .5f, "Morning has an oblique sun for offscreen casting");
|
||||
return 1.0 - 7.0 * sun.x / sun.y;
|
||||
});
|
||||
camera(c, server, shadowX, FLOOR + 5, 1, 30);
|
||||
c.runOnClient(m -> {
|
||||
var state = m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState;
|
||||
var frustum = new Frustum(state.viewRotationMatrix, state.projectionMatrix);
|
||||
frustum.prepare(state.pos.x, state.pos.y, state.pos.z);
|
||||
check(!frustum.isVisible(PILLAR), "Whole pillar is outside the receiver camera frustum");
|
||||
check(!frustum.isVisible(new AABB(SLAB)) && !frustum.isVisible(new AABB(STAIR)),
|
||||
"Slab and stair are also outside the offscreen capture");
|
||||
});
|
||||
shadows(c, false);
|
||||
var offscreenOff = capture(c, "offscreen-off");
|
||||
shadows(c, true);
|
||||
var offscreenOn = capture(c, "offscreen-on");
|
||||
localShadow(compare(offscreenOff, offscreenOn), "Offscreen pillar still casts onto visible ground", .20);
|
||||
|
||||
// Do not toggle/release/rebuild the renderer here: real server edits must invalidate its live cache.
|
||||
server.runOnServer(s -> pillar(s, false));
|
||||
c.waitFor(m -> m.level.getBlockState(new BlockPos(0, FLOOR + 8, 0)).isAir(), 200);
|
||||
activeFrames(c);
|
||||
var removed = capture(c, "offscreen-caster-removed");
|
||||
restoredGround(offscreenOff, offscreenOn, removed);
|
||||
server.runOnServer(s -> pillar(s, true));
|
||||
c.waitFor(m -> m.level.getBlockState(new BlockPos(0, FLOOR + 8, 0)).is(Blocks.CONCRETE.red()), 200);
|
||||
activeFrames(c);
|
||||
var replaced = capture(c, "offscreen-caster-replaced");
|
||||
localShadow(compare(offscreenOff, replaced), "Replacing caster restores its shadow", .20);
|
||||
unchanged(offscreenOn, replaced, "Same caster restores the same ground shadow");
|
||||
|
||||
// beta.128: the moon now casts a weaker shadow using its native celestial angle.
|
||||
server.runOnServer(s -> command(s, "time set 15000"));
|
||||
c.waitFor(m -> PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle).y < 0, 200);
|
||||
camera(c, server, -2, FLOOR + 18, 1, 70);
|
||||
shadows(c, false);
|
||||
var moonOff = capture(c, "moon-off");
|
||||
shadows(c, true);
|
||||
var moonOn = capture(c, "moon-on");
|
||||
var moonStats = opacity(moonOff, moonOn);
|
||||
check(moonStats.dark > 100 && moonStats.maximum > .025 && moonStats.maximum < .14,
|
||||
"Moon creates localized subtle shadows: " + moonStats);
|
||||
unchanged(moonOn, capture(c, null), "Frozen moon shadow is stable");
|
||||
server.runOnServer(s -> command(s, "time set 111000"));
|
||||
c.waitFor(m -> m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.moonPhase.index() == 4
|
||||
&& !PixelShadows.applied(), 200);
|
||||
c.waitTicks(6);
|
||||
int newMoonFrames = c.computeOnClient(m -> PixelShadows.renderedFrames());
|
||||
c.waitTicks(6);
|
||||
c.runOnClient(m -> check(PixelShadows.renderedFrames() == newMoonFrames, "New moon submits no shadow pass"));
|
||||
noonAndFoliage(c, server);
|
||||
|
||||
c.waitFor(m -> PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle).y > .2f,
|
||||
200);
|
||||
shadows(c, true);
|
||||
camera(c, server, -2, FLOOR + 18, 1, 70);
|
||||
c.waitTicks(30);
|
||||
var beforeReload = capture(c, "before-reload");
|
||||
var reload = c.computeOnClient(Minecraft::reloadResourcePacks);
|
||||
c.waitFor(m -> reload.isDone() && m.gui.overlay() == null, 1200);
|
||||
reload.join();
|
||||
c.runOnClient(m -> {
|
||||
forgetPreferences();
|
||||
check(VanillaLight.enabled() && VanillaLight.pixelShadows()
|
||||
&& VanillaLight.shadowPixels() == 16 && VanillaLight.shadowDistance() == 32,
|
||||
"Resource reload and disk reread retain enabled shadow preferences");
|
||||
});
|
||||
activeFrames(c);
|
||||
unchanged(beforeReload, capture(c, "after-reload"), "Resource reload preserves the rendered ground shadow");
|
||||
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"}) {
|
||||
String fullKey = "sanctuary.shaders." + key;
|
||||
check(!Component.translatable(fullKey).getString().equals(fullKey), "Translated " + locale + " " + key);
|
||||
}
|
||||
m.gui.setScreen(new ShaderOptionsScreen(null));
|
||||
widgets(m.gui.screen()).filter(net.minecraft.client.gui.components.CycleButton.class::isInstance)
|
||||
.map(net.minecraft.client.gui.components.AbstractWidget.class::cast).forEach(button -> {
|
||||
check(m.font.width(button.getMessage()) < button.getWidth() - 8,
|
||||
"Localized shader cycle fits: " + button.getMessage().getString());
|
||||
});
|
||||
});
|
||||
c.waitTicks(3);
|
||||
c.takeScreenshot("sanctuary-beta128-shadows-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");
|
||||
} finally {
|
||||
onboarding.set(false);
|
||||
c.runOnClient(m -> {
|
||||
PixelShadows.release();
|
||||
originalOptions.restore(m);
|
||||
IntroClient.setQuickTestIntro(false);
|
||||
try {
|
||||
if (originalPreferences == null) Files.deleteIfExists(path);
|
||||
else Files.write(path, originalPreferences);
|
||||
} catch (IOException e) {
|
||||
throw new AssertionError("Cannot restore shader preferences", e);
|
||||
} finally {
|
||||
forgetPreferences();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void noonAndFoliage(ClientGameTestContext c, TestServerContext server) {
|
||||
// Overhead caster keeps a visible noon shadow while the camera sits below it.
|
||||
server.runOnServer(s -> {
|
||||
pillar(s, false);
|
||||
s.overworld().setBlockAndUpdate(SLAB, Blocks.AIR.defaultBlockState());
|
||||
s.overworld().setBlockAndUpdate(STAIR, Blocks.AIR.defaultBlockState());
|
||||
canopy(s, 1, false, 3);
|
||||
command(s, "time set 6000");
|
||||
});
|
||||
camera(c, server, .99999, FLOOR + 5, 1, 70);
|
||||
c.waitTicks(30);
|
||||
shadows(c, false);
|
||||
var noonOff = capture(c, "noon-off");
|
||||
shadows(c, true);
|
||||
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);
|
||||
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);
|
||||
unchanged(noonOn, capture(c, null), "Noon camera return");
|
||||
look(c, server, 35, 60);
|
||||
shadows(c, false);
|
||||
var obliqueOff = capture(c, "noon-oblique-off");
|
||||
shadows(c, true);
|
||||
var obliqueNoon = capture(c, "noon-oblique");
|
||||
localShadow(compare(obliqueOff, obliqueNoon), "Oblique noon camera sees actual white receiving ground", .10);
|
||||
look(c, server, 35.00002f, 60.00002f);
|
||||
unchanged(obliqueNoon, capture(c, null), "Subpixel rotation with an oblique receiver");
|
||||
// Both sides of noon, frozen individually: no frame-to-frame edge flicker.
|
||||
for (int time : new int[]{5990, 6010}) {
|
||||
server.runOnServer(s -> command(s, "time set " + time));
|
||||
c.waitTicks(10);
|
||||
var stable = capture(c, "noon-" + time);
|
||||
c.waitTicks(10);
|
||||
unchanged(stable, capture(c, null), "Frozen near-noon " + time);
|
||||
}
|
||||
server.runOnServer(s -> command(s, "time set 5999"));
|
||||
c.waitTicks(10);
|
||||
var beforeNoon = capture(c, null);
|
||||
server.runOnServer(s -> command(s, "time set 6001"));
|
||||
c.waitTicks(10);
|
||||
unchanged(beforeNoon, capture(c, "noon-crossing"), "Continuous passage through noon has no large flip");
|
||||
server.runOnServer(s -> {
|
||||
canopy(s, 1, true, 16);
|
||||
command(s, "time set 3000");
|
||||
});
|
||||
camera(c, server, 0, FLOOR + 5, 0, 60);
|
||||
c.waitTicks(30);
|
||||
shadows(c, false);
|
||||
var thinOff = capture(c, "leaves-thin-off");
|
||||
shadows(c, true);
|
||||
var thinOn = capture(c, "leaves-thin-on");
|
||||
var thin = opacity(thinOff, thinOn);
|
||||
c.waitTicks(8);
|
||||
unchanged(thinOn, capture(c, null), "Leaf noise is independent of frame/time");
|
||||
// Keep the live cache: adding inner leaf layers must invalidate it too.
|
||||
server.runOnServer(s -> canopy(s, 4, true, 16));
|
||||
c.waitTicks(30);
|
||||
activeFrames(c);
|
||||
var thickOn = capture(c, "leaves-thick-on");
|
||||
shadows(c, false);
|
||||
var thickOff = capture(c, "leaves-thick-off");
|
||||
var thick = opacity(thickOff, thickOn);
|
||||
server.runOnServer(s -> canopy(s, 4, false, 16));
|
||||
c.waitTicks(30);
|
||||
var solidOff = capture(c, "canopy-solid-off");
|
||||
shadows(c, true);
|
||||
var solid = opacity(solidOff, capture(c, "canopy-solid-on"));
|
||||
check(thin.mean > .02 && thick.mean > thin.mean + .025 && solid.mean > thick.mean + .01,
|
||||
"Depth increases foliage opacity, bounded by solid: thin=" + thin + " thick=" + thick + " solid=" + solid);
|
||||
check(thin.dark > 100 && thin.lit > 100, "Thin foliage has both light holes and shadows: " + thin);
|
||||
server.runOnServer(s -> {
|
||||
for (int x=-16;x<=16;x++) for(int z=-16;z<=16;z++) for(int y=FLOOR+10;y<FLOOR+13;y++)
|
||||
s.overworld().setBlockAndUpdate(new BlockPos(x,y,z), Blocks.AIR.defaultBlockState());
|
||||
canopy(s, 1, true, 16);
|
||||
});
|
||||
c.waitTicks(30);
|
||||
// Crossing a 16-block origin boundary must preserve the same world shadow.
|
||||
camera(c, server, 7.99999, FLOOR + 5, 0, 60);
|
||||
activeFrames(c);
|
||||
var boundary = capture(c, null);
|
||||
camera(c, server, 8.00001, FLOOR + 5, 0, 60);
|
||||
activeFrames(c);
|
||||
unchanged(boundary, capture(c, "anchor-boundary"), "Subpixel motion across 16-block anchor");
|
||||
System.out.println("SHADOWS128_PASS moon/noon plus stable anchors; foliage thin=" + thin + " thick=" + thick + " solid=" + solid);
|
||||
server.runOnServer(s -> {
|
||||
for (int x=-16;x<=16;x++) for(int z=-16;z<=16;z++) for(int y=FLOOR+9;y<FLOOR+13;y++)
|
||||
s.overworld().setBlockAndUpdate(new BlockPos(x,y,z), Blocks.AIR.defaultBlockState());
|
||||
pillar(s, true);
|
||||
});
|
||||
}
|
||||
|
||||
private static void canopy(MinecraftServer s, int layers, boolean leaves, int radius) {
|
||||
var state = leaves ? Blocks.OAK_LEAVES.defaultBlockState().setValue(net.minecraft.world.level.block.LeavesBlock.PERSISTENT, true)
|
||||
: Blocks.CONCRETE.red().defaultBlockState();
|
||||
for (int x=-radius;x<=radius;x++) for(int z=-radius;z<=radius;z++) for(int y=FLOOR+9;y<FLOOR+9+layers;y++)
|
||||
s.overworld().setBlockAndUpdate(new BlockPos(x,y,z), state);
|
||||
}
|
||||
|
||||
private static void crispEdge(Frame off, Frame on) {
|
||||
int samples = 0, intermediate = 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;
|
||||
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++;
|
||||
}
|
||||
check(samples > 1000 && intermediate < samples / 200,
|
||||
"Opaque noon edge stays crisp, no foliage smoothing: " + intermediate + "/" + samples);
|
||||
System.out.println("SHADOWS128_CRISP intermediate=" + intermediate + "/" + samples);
|
||||
}
|
||||
|
||||
private record Opacity(int samples, int dark, int lit, double mean, double maximum) { }
|
||||
private static Opacity opacity(Frame off, Frame on) {
|
||||
sameSize(off,on);
|
||||
int samples=0,dark=0,lit=0;double sum=0,maximum=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 r=a>>16&255,g=a>>8&255,blue=a&255;
|
||||
if(Math.min(r,Math.min(g,blue))<24 || Math.max(r,Math.max(g,blue))-Math.min(r,Math.min(g,blue))>64)continue;
|
||||
double ratio=(brightness(a)-brightness(b))/(double)brightness(a);
|
||||
samples++;sum+=ratio;maximum=Math.max(maximum,ratio);
|
||||
if(ratio>.02)dark++;else if(Math.abs(ratio)<.01)lit++;
|
||||
}
|
||||
check(samples>1000,"White concrete (including blue moonlight) available for shadow opacity");
|
||||
var result=new Opacity(samples,dark,lit,sum/samples,maximum);
|
||||
System.out.println("SHADOWS128_OPACITY " + result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static java.util.stream.Stream<net.minecraft.client.gui.components.events.GuiEventListener> widgets(
|
||||
net.minecraft.client.gui.components.events.ContainerEventHandler root) {
|
||||
return root.children().stream().flatMap(widget -> widget instanceof net.minecraft.client.gui.components.events.ContainerEventHandler child
|
||||
? java.util.stream.Stream.concat(java.util.stream.Stream.of(widget), widgets(child)) : java.util.stream.Stream.of(widget));
|
||||
}
|
||||
|
||||
private static void checkPreferences() {
|
||||
VanillaLight.setEnabled(true);
|
||||
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}}) {
|
||||
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}}) {
|
||||
VanillaLight.setShadowDistance(pair[0]);
|
||||
forgetPreferences();
|
||||
check(VanillaLight.shadowDistance() == pair[1], "Persisted shadow distance rounds/clamps " + pair[0]);
|
||||
}
|
||||
for (boolean enabled : new boolean[]{true, false}) {
|
||||
VanillaLight.setPixelShadows(enabled);
|
||||
forgetPreferences();
|
||||
check(VanillaLight.pixelShadows() == enabled && VanillaLight.enabled(), "Saved shadow toggle is independent of main shader");
|
||||
}
|
||||
VanillaLight.setShadowPixels(16);
|
||||
VanillaLight.setShadowDistance(32);
|
||||
}
|
||||
|
||||
private static void forgetPreferences() {
|
||||
try {
|
||||
var field = VanillaLight.class.getDeclaredField("preferences");
|
||||
field.setAccessible(true);
|
||||
field.set(null, null);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new AssertionError("Cannot reread actual shader preferences", e);
|
||||
}
|
||||
}
|
||||
|
||||
private 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");
|
||||
p.setDeltaMovement(Vec3.ZERO);
|
||||
});
|
||||
c.waitFor(m -> m.player.position().distanceToSqr(new Vec3(x, y, z)) < .0001
|
||||
&& Math.abs(m.player.getXRot() - 90) < .001, 200);
|
||||
c.runOnClient(m -> {
|
||||
m.options.fov().set(fov);
|
||||
m.player.setYRot(0);
|
||||
m.player.yRotO = 0;
|
||||
m.player.setXRot(90);
|
||||
m.player.xRotO = 90;
|
||||
m.player.setDeltaMovement(Vec3.ZERO);
|
||||
});
|
||||
c.waitTicks(15);
|
||||
}
|
||||
|
||||
private 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);
|
||||
});
|
||||
c.waitFor(m -> Math.abs(m.player.getXRot() - pitch) < .00001
|
||||
&& Math.abs(m.player.getYRot() - yaw) < .00001, 200);
|
||||
c.waitTicks(15);
|
||||
}
|
||||
|
||||
private static void pillar(MinecraftServer s, boolean present) {
|
||||
for (int x = 0; x < 2; x++) for (int z = 0; z < 2; z++) for (int y = FLOOR + 1; y <= FLOOR + 8; y++) {
|
||||
s.overworld().setBlockAndUpdate(new BlockPos(x, y, z),
|
||||
(present ? Blocks.CONCRETE.red() : Blocks.AIR).defaultBlockState());
|
||||
}
|
||||
}
|
||||
|
||||
private 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());
|
||||
} catch (com.mojang.brigadier.exceptions.CommandSyntaxException e) {
|
||||
throw new AssertionError("Fixture command failed: " + command, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void shadows(ClientGameTestContext c, boolean enabled) {
|
||||
c.runOnClient(m -> VanillaLight.setPixelShadows(enabled));
|
||||
if (enabled) activeFrames(c);
|
||||
else {
|
||||
c.waitTicks(6);
|
||||
int frames = c.computeOnClient(m -> {
|
||||
check(!PixelShadows.applied() && PixelShadows.shadowMap() == null, "OFF releases the GPU shadow target");
|
||||
return PixelShadows.renderedFrames();
|
||||
});
|
||||
c.waitTicks(3);
|
||||
c.runOnClient(m -> check(PixelShadows.renderedFrames() == frames, "OFF submits no shadow frames"));
|
||||
}
|
||||
}
|
||||
|
||||
private 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);
|
||||
var result = new CompletableFuture<Frame>();
|
||||
c.runOnClient(m -> Screenshot.takeScreenshot(m.gameRenderer.mainRenderTarget(), image -> {
|
||||
try (image) {
|
||||
result.complete(new Frame(image.getWidth(), image.getHeight(), image.getPixels()));
|
||||
} catch (Throwable e) {
|
||||
result.completeExceptionally(e);
|
||||
}
|
||||
}));
|
||||
c.waitFor(m -> result.isDone(), 200);
|
||||
return result.join();
|
||||
}
|
||||
|
||||
private 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) {
|
||||
sameSize(before, after);
|
||||
int sampled = 0, ground = 0, dark = 0, stable = 0, brighter = 0;
|
||||
// Skip borders and sample every other pixel; no PNG decoding, image libraries or golden screenshot dependency.
|
||||
for (int y = before.height / 10; y < before.height * 9 / 10; y += 2) {
|
||||
for (int x = before.width / 10; x < before.width * 9 / 10; x += 2) {
|
||||
sampled++;
|
||||
int a = before.pixels[y * before.width + x], b = after.pixels[y * after.width + x];
|
||||
if (!whiteGround(a)) continue;
|
||||
ground++;
|
||||
int delta = brightness(a) - brightness(b);
|
||||
if (delta >= DARK_DELTA) dark++;
|
||||
if (delta <= -DARK_DELTA) brighter++;
|
||||
if (channelDifference(a, b) <= 3) stable++;
|
||||
}
|
||||
}
|
||||
return new Difference(sampled, ground, dark, stable, brighter);
|
||||
}
|
||||
|
||||
private static void localShadow(Difference d, String label, double minimumStable) {
|
||||
System.out.println("PIXEL_SHADOWS122_PIXELS " + label + ": " + d);
|
||||
check(d.ground > d.sampled * .50, label + ": a majority of samples must be actual white receiving ground: " + d);
|
||||
check(d.dark >= Math.max(24, d.ground / 1000), label + ": measurable cast-shadow darkening: " + d);
|
||||
check(d.stable > d.ground * minimumStable, label + ": unshadowed ground stays unchanged; reject a global tint: " + d);
|
||||
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) {
|
||||
sameSize(before, after);
|
||||
int sampled = 0, changed = 0;
|
||||
for (int y = before.height / 10; y < before.height * 9 / 10; y += 2) {
|
||||
for (int x = before.width / 10; x < before.width * 9 / 10; x += 2) {
|
||||
sampled++;
|
||||
int i = y * before.width + x;
|
||||
if (channelDifference(before.pixels[i], after.pixels[i]) > 3) changed++;
|
||||
}
|
||||
}
|
||||
check(sampled > 0 && changed <= sampled / 100, label + ": changed=" + changed + "/" + sampled);
|
||||
}
|
||||
|
||||
private static void restoredGround(Frame off, Frame on, Frame removed) {
|
||||
sameSize(off, on);
|
||||
sameSize(off, removed);
|
||||
int shadow = 0, restored = 0, lit = 0, stable = 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;
|
||||
if (!whiteGround(off.pixels[i])) continue;
|
||||
if (brightness(off.pixels[i]) - brightness(on.pixels[i]) >= DARK_DELTA) {
|
||||
shadow++;
|
||||
if (brightness(removed.pixels[i]) - brightness(on.pixels[i]) >= DARK_DELTA
|
||||
&& channelDifference(off.pixels[i], removed.pixels[i]) <= 4) restored++;
|
||||
} else if (channelDifference(off.pixels[i], on.pixels[i]) <= 3) {
|
||||
lit++;
|
||||
if (channelDifference(on.pixels[i], removed.pixels[i]) <= 3) stable++;
|
||||
}
|
||||
}
|
||||
}
|
||||
check(shadow >= 24 && restored > shadow * .85,
|
||||
"Removing offscreen caster relights its former shadow: " + restored + "/" + shadow);
|
||||
check(lit >= 24 && stable > lit * .98,
|
||||
"Removing caster preserves already sunlit ground: " + stable + "/" + lit);
|
||||
System.out.println("PIXEL_SHADOWS122_EDIT restored=" + restored + "/" + shadow + " litStable=" + stable + "/" + lit);
|
||||
}
|
||||
|
||||
private static boolean whiteGround(int color) {
|
||||
int r = color >> 16 & 255, g = color >> 8 & 255, b = color & 255;
|
||||
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) {
|
||||
return ((color >> 16 & 255) + (color >> 8 & 255) + (color & 255)) / 3;
|
||||
}
|
||||
|
||||
private 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))));
|
||||
}
|
||||
|
||||
private static void sameSize(Frame a, Frame b) {
|
||||
check(a.width == b.width && a.height == b.height && a.pixels.length == b.pixels.length,
|
||||
"GPU comparison requires the same framebuffer dimensions");
|
||||
}
|
||||
|
||||
private record Options(int distance, int simulationDistance, int fov, boolean bob,
|
||||
boolean pause, CameraType camera, String language) {
|
||||
static Options capture(Minecraft m) {
|
||||
return new Options(m.options.renderDistance().get(), m.options.simulationDistance().get(), m.options.fov().get(),
|
||||
m.options.bobView().get(), m.options.pauseOnLostFocus,
|
||||
m.options.getCameraType(), m.getLanguageManager().getSelected());
|
||||
}
|
||||
|
||||
void restore(Minecraft m) {
|
||||
m.options.renderDistance().set(distance);
|
||||
m.options.simulationDistance().set(simulationDistance);
|
||||
m.options.fov().set(fov);
|
||||
m.options.bobView().set(bob);
|
||||
m.options.pauseOnLostFocus = pause;
|
||||
m.options.setCameraType(camera);
|
||||
m.getLanguageManager().setSelected(language);
|
||||
m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
|
||||
}
|
||||
}
|
||||
|
||||
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); }
|
||||
}
|
||||
@@ -19,6 +19,7 @@ public final class SanctuaryKnowledgeClient implements ClientModInitializer {
|
||||
public static void registerClient() {
|
||||
SanctuaryResourcePack.register();
|
||||
VortexCloudRenderer.register();
|
||||
fr.koka.sanctuary.client.shader.PixelShadows.register();
|
||||
ProgressionClient.register();
|
||||
StarClient.register();
|
||||
WeatherClient.register();
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
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.blaze3d.vertex.DefaultVertexFormat;
|
||||
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.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.resources.Identifier;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.client.renderer.BindGroupLayouts;
|
||||
import net.minecraft.client.renderer.MappableRingBuffer;
|
||||
import net.minecraft.client.renderer.RenderPipelines;
|
||||
import net.minecraft.client.renderer.state.level.CameraRenderState;
|
||||
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;
|
||||
|
||||
/** Native sun/moon depth map and world-grid receivers. Never reads GPU pixels or changes gameplay light. */
|
||||
public final class PixelShadows {
|
||||
private static final RenderPipeline DEPTH = RenderPipelines.register(RenderPipeline.builder()
|
||||
.withLocation(Identifier.parse("sanctuary:pipeline/pixel_shadow_depth"))
|
||||
.withVertexShader(Identifier.parse("sanctuary:core/pixel_shadow_depth"))
|
||||
.withFragmentShader(Identifier.parse("sanctuary:core/pixel_shadow_depth"))
|
||||
.withBindGroupLayout(BindGroupLayouts.DYNAMIC_TRANSFORMS)
|
||||
.withVertexBinding(0, DefaultVertexFormat.POSITION_COLOR)
|
||||
.withPrimitiveTopology(PrimitiveTopology.QUADS).withCull(false)
|
||||
.withColorTargetState(new ColorTargetState(Optional.empty(), GpuFormat.R8_UNORM, ColorTargetState.WRITE_COLOR))
|
||||
.withDepthStencilState(new DepthStencilState(CompareOp.LESS_THAN, true)).build());
|
||||
private static final RenderPipeline APPLY = RenderPipelines.register(RenderPipeline.builder()
|
||||
.withLocation(Identifier.parse("sanctuary:pipeline/pixel_shadows"))
|
||||
.withVertexShader("core/screenquad")
|
||||
.withFragmentShader(Identifier.parse("sanctuary:core/pixel_shadows"))
|
||||
.withBindGroupLayout(BindGroupLayout.builder()
|
||||
.withUniform("PixelShadows", UniformType.UNIFORM_BUFFER)
|
||||
.withUniform("WorldDepth", UniformType.COMBINED_IMAGE_SAMPLER)
|
||||
.withUniform("SunDepth", UniformType.COMBINED_IMAGE_SAMPLER)
|
||||
.withUniform("SunMaterial", UniformType.COMBINED_IMAGE_SAMPLER).build())
|
||||
.withPrimitiveTopology(PrimitiveTopology.TRIANGLES).withCull(false)
|
||||
.withColorTargetState(new ColorTargetState(Optional.of(BlendFunction.TRANSLUCENT), GpuFormat.RGBA8_UNORM, ColorTargetState.WRITE_COLOR))
|
||||
.withDepthStencilState(Optional.empty()).build());
|
||||
private static final ShadowTerrain TERRAIN = new ShadowTerrain();
|
||||
private static final Matrix4f projection = new Matrix4f();
|
||||
private static TextureTarget shadowMap;
|
||||
private static MappableRingBuffer uniforms;
|
||||
private static int renderedFrames;
|
||||
private static boolean applied;
|
||||
|
||||
private PixelShadows() { }
|
||||
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); }
|
||||
public static void dirtyBlock(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++) TERRAIN.dirtySection(x,y,z);
|
||||
}
|
||||
public static void dirtyChunk(int x, int z) { TERRAIN.dirtyChunk(x, z); }
|
||||
|
||||
/** Called after the world's depth is resolved and before the first-person hand and GUI. */
|
||||
public static void render() {
|
||||
applied = false;
|
||||
var client = Minecraft.getInstance();
|
||||
if (!VanillaLight.active() || !VanillaLight.pixelShadows() || client.level == null) {
|
||||
release();
|
||||
return;
|
||||
}
|
||||
if (PanoramaCapture.rendering()) return;
|
||||
var state = client.gameRenderer.gameRenderState().levelRenderState;
|
||||
var sky = state.skyRenderState;
|
||||
CameraRenderState camera = state.cameraRenderState;
|
||||
var sun = sunDirection(sky.sunAngle);
|
||||
boolean lunar = sun.y <= .08f;
|
||||
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))
|
||||
* Math.clamp((sun.y - .08f) / .16f, 0, 1) * sky.rainBrightness;
|
||||
if (strength <= .001f) return;
|
||||
int distance = VanillaLight.shadowDistance(), pixels = VanillaLight.shadowPixels();
|
||||
TERRAIN.update(client.level, camera.pos, distance, sun);
|
||||
int size = mapSize(distance, pixels);
|
||||
if (shadowMap == null || shadowMap.width != size) {
|
||||
if (shadowMap != null) shadowMap.destroyBuffers();
|
||||
shadowMap = new TextureTarget("Sanctuary celestial shadows", size, size, GpuFormat.R8_UNORM, GpuFormat.D32_FLOAT);
|
||||
}
|
||||
if (uniforms == null) uniforms = new MappableRingBuffer(() -> "Sanctuary pixel shadows",
|
||||
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 inverse = new Matrix4f(projection).mul(camera.viewRotationMatrix).invert();
|
||||
uniforms.rotate();
|
||||
var fog = camera.fogData;
|
||||
try (var mapped = uniforms.currentBuffer().map(false, true)) {
|
||||
Std140Builder.intoBuffer(mapped.data()).putMat4f(inverse).putMat4f(light)
|
||||
.putVec4((float)(camera.pos.x - ox), (float)(camera.pos.y - oy), (float)(camera.pos.z - oz), pixels)
|
||||
.putVec4(sun.x, sun.y, sun.z, strength)
|
||||
.putVec4(distance, zeroToOne ? 1 : 0, fog.renderDistanceStart, fog.renderDistanceEnd)
|
||||
.putVec4(fog.environmentalStart, fog.environmentalEnd, fog.color.w, 0);
|
||||
}
|
||||
var entries = TERRAIN.entries();
|
||||
var transforms = new ArrayList<GpuBufferSlice>(entries.size());
|
||||
int maxIndices = 0;
|
||||
for (var entry : entries) {
|
||||
transforms.add(RenderSystem.getDynamicUniforms().writeTransform(new Matrix4f(light)
|
||||
.translate((float)(entry.x() - ox), (float)(entry.y() - oy), (float)(entry.z() - oz)),
|
||||
new Vector4f(1, 1, 1, 1),
|
||||
// Bounded world coordinates keep foliage noise precise far from spawn.
|
||||
new Vector3f(Math.floorMod(entry.x(), 4096), Math.floorMod(entry.y(), 4096),
|
||||
Math.floorMod(entry.z(), 4096)), new Matrix4f()));
|
||||
maxIndices = Math.max(maxIndices, entry.indexCount());
|
||||
}
|
||||
var indices = RenderSystem.getSequentialBuffer(PrimitiveTopology.QUADS);
|
||||
indices.requestIndexCount(maxIndices);
|
||||
var encoder = RenderSystem.getDevice().createCommandEncoder();
|
||||
try (var pass = encoder.createRenderPass(com.mojang.renderpearl.api.commands.RenderPassDescriptor.builder(() -> "Sanctuary celestial depth")
|
||||
.withColorAttachment(shadowMap.getColorTextureView(), Optional.of(new Vector4f(0, 0, 0, 0)))
|
||||
.withDepthAttachment(shadowMap.getDepthTextureView(), OptionalDouble.of(1)).build())) {
|
||||
pass.setPipeline(RenderSystem.getCompiledPipeline(DEPTH));
|
||||
if (maxIndices > 0) 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.indexCount(), 1, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
var main = client.gameRenderer.mainRenderTarget();
|
||||
var nearest = RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST);
|
||||
try (var pass = encoder.createRenderPass(() -> "Sanctuary pixel shadows", main.getColorTextureView(), Optional.empty())) {
|
||||
pass.setPipeline(RenderSystem.getCompiledPipeline(APPLY));
|
||||
pass.setUniform("PixelShadows", uniforms.currentBuffer());
|
||||
pass.setUniform("WorldDepth", main.getDepthTextureView(), nearest);
|
||||
pass.setUniform("SunDepth", shadowMap.getDepthTextureView(), nearest);
|
||||
pass.setUniform("SunMaterial", shadowMap.getColorTextureView(), nearest);
|
||||
pass.draw(3, 1, 0, 0);
|
||||
}
|
||||
renderedFrames++;
|
||||
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 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();
|
||||
if (shadowMap != null) { shadowMap.destroyBuffers(); shadowMap = null; }
|
||||
if (uniforms != null) { uniforms.close(); uniforms = null; }
|
||||
applied = false;
|
||||
}
|
||||
static int renderedFrames() { return renderedFrames; }
|
||||
static boolean applied() { return applied; }
|
||||
static boolean ready() { return TERRAIN.settled(); }
|
||||
static TextureTarget shadowMap() { return shadowMap; }
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package fr.koka.sanctuary.client.shader;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import java.util.List;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.OptionInstance;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
@@ -15,5 +17,14 @@ 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(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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
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.blaze3d.vertex.DefaultVertexFormat;
|
||||
import com.mojang.renderpearl.api.buffers.GpuBuffer;
|
||||
import com.mojang.renderpearl.api.pipeline.PrimitiveTopology;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.tags.BlockTags;
|
||||
import net.minecraft.core.SectionPos;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.LevelChunkSection;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import org.joml.Vector3fc;
|
||||
|
||||
/**
|
||||
* Render-thread-only, read-only terrain casters. Call update BEFORE opening any render pass.
|
||||
* Positions use POSITION_COLOR (three floats plus a foliage flag), relative to the entry's block
|
||||
* origin; add that origin in the draw transform. Indices belong to RenderSystem's shared
|
||||
* QUADS sequential buffer, which update grows before drawing. This cache never owns it.
|
||||
*
|
||||
* <p>Call dirtySection for edits (including affected neighbors), or dirtyChunk for a chunk
|
||||
* packet that mutates an existing chunk. Identity changes/unloads are detected automatically.
|
||||
* Call close on disable/disconnect/resource teardown; it is idempotent and the cache is reusable.
|
||||
* 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;
|
||||
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;
|
||||
private static final int MAX_SECTION_QUADS = 65_536;
|
||||
private static final int MAX_SHAPES = 1024;
|
||||
private static final int MAX_BOXES = 256;
|
||||
private static final Direction[] DIRECTIONS = Direction.values();
|
||||
|
||||
/** Borrowed buffer: owned exclusively by this cache, valid until its next update/close. */
|
||||
public record Entry(int x, int y, int z, GpuBuffer vertices, int indexCount) { }
|
||||
|
||||
private final Map<Long, LevelChunk> chunks = new HashMap<>();
|
||||
private final Map<Long, Section> sections = new HashMap<>();
|
||||
private final Map<BlockState, Shape> shapes = new IdentityHashMap<>();
|
||||
private ClientLevel level;
|
||||
private Bounds bounds;
|
||||
private Build building;
|
||||
private long gpuBytes;
|
||||
private List<Entry> entries = List.of();
|
||||
private boolean entriesChanged;
|
||||
private int cameraSectionX, cameraSectionY, cameraSectionZ;
|
||||
|
||||
/**
|
||||
* Refreshes loaded section metadata, then meshes at most four sections within a soft 3 ms
|
||||
* budget. A large section resumes next call; a single shape lookup or GPU upload cannot be
|
||||
* preempted. distance is in blocks, 0..MAX_DISTANCE. The receiver volume plus an eight-block guard
|
||||
* is extruded by twice that distance toward the sun, rounded to sections and clipped
|
||||
* to the level's build height. This includes loaded casters outside the camera frustum.
|
||||
*
|
||||
* <p>The 96 MiB vertex budget favors nearer sections. Evicted/deferred meshes are retried
|
||||
* when room becomes available or the camera enters another section. Pathological geometry
|
||||
* exceeding 256 boxes per state or 65,536 quads per section is omitted, never approximated
|
||||
* by a full cube or uploaded partially. It can be retried after invalidation.
|
||||
*/
|
||||
public List<Entry> update(ClientLevel level, Vec3 camera, int distance, Vector3fc sun) {
|
||||
RenderSystem.assertOnRenderThread();
|
||||
if (level == null) {
|
||||
close();
|
||||
return entries;
|
||||
}
|
||||
Objects.requireNonNull(camera, "camera");
|
||||
if (distance < 0 || distance > MAX_DISTANCE || !Double.isFinite(camera.x)
|
||||
|| !Double.isFinite(camera.y) || !Double.isFinite(camera.z)) {
|
||||
throw new IllegalArgumentException("Finite camera and shadow distance 0.." + MAX_DISTANCE + " required");
|
||||
}
|
||||
if (this.level != level) {
|
||||
close();
|
||||
this.level = level;
|
||||
}
|
||||
int radius = distance + 8;
|
||||
double dx = sun.x() * distance * 2, dy = sun.y() * distance * 2, dz = sun.z() * distance * 2;
|
||||
Bounds next = new Bounds(section(camera.x - radius + Math.min(0, dx)), section(camera.x + radius + Math.max(0, dx)),
|
||||
Math.max(level.getMinSectionY(), section(camera.y - radius + Math.min(0, dy))),
|
||||
Math.min(level.getMaxSectionY() - 1, section(camera.y + radius + Math.max(0, dy))),
|
||||
section(camera.z - radius + Math.min(0, dz)), section(camera.z + radius + Math.max(0, dz)));
|
||||
int cx = section(camera.x), cy = section(camera.y), cz = section(camera.z);
|
||||
boolean moved = !next.equals(bounds) || cx != cameraSectionX || cy != cameraSectionY || cz != cameraSectionZ;
|
||||
if (bounds != null && !next.equals(bounds)) {
|
||||
// Changing the caster boundary changes which neighboring faces may be culled.
|
||||
for (Section section : sections.values()) {
|
||||
if (bounds.onBoundary(section) || next.onBoundary(section)) dirty(section);
|
||||
}
|
||||
}
|
||||
bounds = next;
|
||||
cameraSectionX = cx;
|
||||
cameraSectionY = cy;
|
||||
cameraSectionZ = cz;
|
||||
reconcile();
|
||||
if (moved) {
|
||||
retryDeferred();
|
||||
}
|
||||
|
||||
long deadline = System.nanoTime() + BUILD_NANOS;
|
||||
int started = building == null ? 0 : 1;
|
||||
try {
|
||||
while (System.nanoTime() < deadline) {
|
||||
if (building == null) {
|
||||
if (started == MAX_SECTIONS_PER_UPDATE) break;
|
||||
Section nearest = nearestDirty(camera);
|
||||
if (nearest == null) break;
|
||||
building = new Build(nearest);
|
||||
started++;
|
||||
}
|
||||
if (!advance(building, deadline)) break;
|
||||
Build finished = building;
|
||||
building = null;
|
||||
try (finished) {
|
||||
upload(finished, camera);
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException | Error failure) {
|
||||
cancelBuild();
|
||||
throw failure;
|
||||
}
|
||||
return entries();
|
||||
}
|
||||
|
||||
/** Immutable snapshot; consume after update and before another update or close. */
|
||||
public List<Entry> entries() {
|
||||
RenderSystem.assertOnRenderThread();
|
||||
if (entriesChanged) {
|
||||
var result = new ArrayList<Entry>();
|
||||
for (Section section : sections.values()) {
|
||||
if (section.entry != null) result.add(section.entry);
|
||||
}
|
||||
entries = List.copyOf(result);
|
||||
entriesChanged = false;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Outstanding rebuilds, including the in-progress section; excludes memory-deferred work. */
|
||||
public int pendingSections() {
|
||||
RenderSystem.assertOnRenderThread();
|
||||
int pending = 0;
|
||||
for (Section section : sections.values()) {
|
||||
if (section.dirty && !section.deferred) pending++;
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** No runnable mesh work after the last update; may still omit memory/complexity-limited sections. */
|
||||
public boolean settled() {
|
||||
return pendingSections() == 0;
|
||||
}
|
||||
|
||||
/** Coordinates are section coordinates, not block origins. Caller also invalidates neighbors. */
|
||||
public void dirtySection(int sx, int sy, int sz) {
|
||||
RenderSystem.assertOnRenderThread();
|
||||
Section section = sections.get(SectionPos.asLong(sx, sy, sz));
|
||||
if (section != null) dirty(section);
|
||||
}
|
||||
|
||||
/** Coordinates are chunk coordinates. Caller also invalidates adjacent chunks as needed. */
|
||||
public void dirtyChunk(int cx, int cz) {
|
||||
RenderSystem.assertOnRenderThread();
|
||||
for (Section section : sections.values()) {
|
||||
if (section.x == cx && section.z == cz) dirty(section);
|
||||
}
|
||||
}
|
||||
|
||||
private void dirty(Section section) {
|
||||
section.dirty = true;
|
||||
section.deferred = false;
|
||||
if (building != null && building.section == section) cancelBuild();
|
||||
}
|
||||
|
||||
/** Poll only chunk identities and section emptiness; never scan unchanged block storage. */
|
||||
private void reconcile() {
|
||||
var loaded = new HashMap<Long, LevelChunk>();
|
||||
if (bounds.minY <= bounds.maxY) {
|
||||
for (int x = bounds.minX; x <= bounds.maxX; x++) {
|
||||
for (int z = bounds.minZ; z <= bounds.maxZ; z++) {
|
||||
LevelChunk chunk = level.getChunkSource().getChunk(x, z, ChunkStatus.FULL, false);
|
||||
long key = ChunkPos.pack(x, z);
|
||||
if (chunk != null) loaded.put(key, chunk);
|
||||
if (chunks.get(key) != chunk) dirtyChunkAndNeighbors(x, z);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (long key : chunks.keySet()) {
|
||||
if (!loaded.containsKey(key)) dirtyChunkAndNeighbors(ChunkPos.getX(key), ChunkPos.getZ(key));
|
||||
}
|
||||
chunks.clear();
|
||||
chunks.putAll(loaded);
|
||||
|
||||
boolean freed = false;
|
||||
for (Iterator<Section> iterator = sections.values().iterator(); iterator.hasNext();) {
|
||||
Section section = iterator.next();
|
||||
LevelChunk chunk = chunks.get(ChunkPos.pack(section.x, section.z));
|
||||
if (!bounds.contains(section.x, section.y, section.z) || chunk != section.chunk
|
||||
|| chunk.getSection(level.getSectionIndexFromSectionY(section.y)) != section.storage
|
||||
|| section.storage.hasOnlyAir()) {
|
||||
if (building != null && building.section == section) cancelBuild();
|
||||
freed |= section.entry != null;
|
||||
release(section);
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
for (LevelChunk chunk : chunks.values()) {
|
||||
int x = chunk.getPos().x(), z = chunk.getPos().z();
|
||||
for (int y = bounds.minY; y <= bounds.maxY; y++) {
|
||||
LevelChunkSection storage = chunk.getSection(level.getSectionIndexFromSectionY(y));
|
||||
long key = SectionPos.asLong(x, y, z);
|
||||
if (!storage.hasOnlyAir() && !sections.containsKey(key)) {
|
||||
sections.put(key, new Section(x, y, z, chunk, storage));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (freed) retryDeferred();
|
||||
}
|
||||
|
||||
private void dirtyChunkAndNeighbors(int x, int z) {
|
||||
dirtyChunk(x, z);
|
||||
dirtyChunk(x - 1, z);
|
||||
dirtyChunk(x + 1, z);
|
||||
dirtyChunk(x, z - 1);
|
||||
dirtyChunk(x, z + 1);
|
||||
}
|
||||
|
||||
private void retryDeferred() {
|
||||
for (Section section : sections.values()) {
|
||||
if (section.deferred) {
|
||||
section.deferred = false;
|
||||
section.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Section nearestDirty(Vec3 camera) {
|
||||
Section nearest = null;
|
||||
double best = Double.POSITIVE_INFINITY;
|
||||
for (Section section : sections.values()) {
|
||||
if (section.dirty && !section.deferred) {
|
||||
double distance = section.distanceSquared(camera);
|
||||
if (distance < best) {
|
||||
best = distance;
|
||||
nearest = section;
|
||||
}
|
||||
}
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
|
||||
private boolean advance(Build build, long deadline) {
|
||||
while (build.cursor < 4096) {
|
||||
int index = build.cursor++;
|
||||
int x = index & 15, z = (index >> 4) & 15, y = index >> 8;
|
||||
var state = build.section.storage.getBlockState(x, y, z);
|
||||
boolean foliage = state.is(BlockTags.LEAVES);
|
||||
Shape shape = foliage ? LEAVES : shape(state);
|
||||
if (shape != null) {
|
||||
if (shape.boxes == null) {
|
||||
build.oversized = true;
|
||||
return true;
|
||||
}
|
||||
int hidden = 0;
|
||||
for (Direction direction : DIRECTIONS) {
|
||||
if (foliage) break; // Retain internal leaf faces: each layer contributes transmission.
|
||||
BlockState neighbor = neighbor(build.section, x + direction.getStepX(),
|
||||
y + direction.getStepY(), z + direction.getStepZ());
|
||||
Shape other = neighbor == null ? null : shape(neighbor);
|
||||
if (other != null && (other.fullFaces & (1 << direction.getOpposite().ordinal())) != 0) {
|
||||
hidden |= 1 << direction.ordinal();
|
||||
}
|
||||
}
|
||||
for (AABB box : shape.boxes) {
|
||||
for (Direction direction : DIRECTIONS) {
|
||||
if ((hidden & (1 << direction.ordinal())) != 0 && atBoundary(box, direction)) continue;
|
||||
if (build.quads == MAX_SECTION_QUADS) {
|
||||
build.oversized = true;
|
||||
return true;
|
||||
}
|
||||
face(build.vertices, box, x, y, z, direction, foliage ? 0xFFFFFFFF : 0xFF000000);
|
||||
build.quads++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ((build.cursor & 31) == 0 && System.nanoTime() >= deadline) return build.cursor == 4096;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private BlockState neighbor(Section section, int x, int y, int z) {
|
||||
if ((x | y | z) >= 0 && x < 16 && y < 16 && z < 16) {
|
||||
return section.storage.getBlockState(x, y, z);
|
||||
}
|
||||
int sx = section.x + (x >> 4), sy = section.y + (y >> 4), sz = section.z + (z >> 4);
|
||||
// Keep the exterior faces of the caster volume; no dependency on chunks outside it.
|
||||
if (!bounds.contains(sx, sy, sz)) return null;
|
||||
LevelChunk chunk = chunks.get(ChunkPos.pack(sx, sz));
|
||||
if (chunk == null) return null;
|
||||
return chunk.getSection(level.getSectionIndexFromSectionY(sy)).getBlockState(x & 15, y & 15, z & 15);
|
||||
}
|
||||
|
||||
private Shape shape(BlockState state) {
|
||||
// Retain the solid part of waterlogged slabs/stairs, but never mesh the fluid itself.
|
||||
if (state.isAir() || state.is(BlockTags.LEAVES) || !state.canOcclude() || state.liquid()) return null;
|
||||
Shape cached = shapes.get(state);
|
||||
if (cached != null) return cached;
|
||||
// In 26.3 occlusion shapes are cached on BlockState and take no level/position argument.
|
||||
var voxel = state.getOcclusionShape();
|
||||
var boxes = new ArrayList<AABB>();
|
||||
voxel.forAllBoxes((x0, y0, z0, x1, y1, z1) -> {
|
||||
if (boxes.size() <= MAX_BOXES) boxes.add(new AABB(x0, y0, z0, x1, y1, z1));
|
||||
});
|
||||
int fullFaces = 0;
|
||||
for (Direction direction : DIRECTIONS) {
|
||||
if (Block.isFaceFull(voxel, direction)) fullFaces |= 1 << direction.ordinal();
|
||||
}
|
||||
// An omitted shape must not hide the faces of the surrounding geometry.
|
||||
Shape result = boxes.size() > MAX_BOXES ? new Shape(null, 0) : new Shape(boxes.toArray(AABB[]::new), fullFaces);
|
||||
if (shapes.size() == MAX_SHAPES) shapes.clear();
|
||||
shapes.put(state, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void upload(Build build, Vec3 camera) {
|
||||
Section section = build.section;
|
||||
if (build.oversized || build.quads == 0) {
|
||||
release(section);
|
||||
section.dirty = false;
|
||||
retryDeferred();
|
||||
return;
|
||||
}
|
||||
try (var mesh = build.vertices.buildOrThrow()) {
|
||||
long bytes = mesh.vertexBuffer().remaining();
|
||||
long previous = section.entry == null ? 0 : section.entry.vertices.size();
|
||||
// Release the old buffer before replacement, so even transient owned GPU memory is bounded.
|
||||
release(section);
|
||||
while (gpuBytes + bytes > MAX_GPU_BYTES) {
|
||||
Section farthest = null;
|
||||
double far = section.distanceSquared(camera);
|
||||
for (Section candidate : sections.values()) {
|
||||
double distance = candidate.distanceSquared(camera);
|
||||
if (candidate.entry != null && distance > far) {
|
||||
farthest = candidate;
|
||||
far = distance;
|
||||
}
|
||||
}
|
||||
if (farthest == null) {
|
||||
section.deferred = true;
|
||||
return;
|
||||
}
|
||||
release(farthest);
|
||||
farthest.dirty = true;
|
||||
farthest.deferred = true;
|
||||
}
|
||||
int indexCount = mesh.drawState().indexCount();
|
||||
RenderSystem.getSequentialBuffer(PrimitiveTopology.QUADS).getBuffer(indexCount);
|
||||
GpuBuffer buffer = RenderSystem.getDevice().createBuffer(() -> "Sanctuary shadow terrain "
|
||||
+ section.x + "," + section.y + "," + section.z,
|
||||
GpuBuffer.USAGE_VERTEX | GpuBuffer.USAGE_COPY_DST, mesh.vertexBuffer());
|
||||
section.entry = new Entry(section.x << 4, section.y << 4, section.z << 4, buffer, indexCount);
|
||||
gpuBytes += buffer.size();
|
||||
section.dirty = false;
|
||||
entriesChanged = true;
|
||||
if (bytes < previous) retryDeferred();
|
||||
}
|
||||
}
|
||||
|
||||
private void release(Section section) {
|
||||
if (section.entry != null) {
|
||||
GpuBuffer buffer = section.entry.vertices;
|
||||
gpuBytes -= buffer.size();
|
||||
buffer.close();
|
||||
section.entry = null;
|
||||
entriesChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void cancelBuild() {
|
||||
if (building != null) {
|
||||
building.close();
|
||||
building = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
RenderSystem.assertOnRenderThread();
|
||||
cancelBuild();
|
||||
for (Section section : sections.values()) release(section);
|
||||
sections.clear();
|
||||
chunks.clear();
|
||||
shapes.clear();
|
||||
entries = List.of();
|
||||
entriesChanged = false;
|
||||
gpuBytes = 0;
|
||||
bounds = null;
|
||||
level = null;
|
||||
}
|
||||
|
||||
private static int section(double coordinate) {
|
||||
return (int) Math.floor(coordinate / 16.0);
|
||||
}
|
||||
|
||||
private static boolean atBoundary(AABB box, Direction direction) {
|
||||
return switch (direction) {
|
||||
case DOWN -> box.minY == 0;
|
||||
case UP -> box.maxY == 1;
|
||||
case NORTH -> box.minZ == 0;
|
||||
case SOUTH -> box.maxZ == 1;
|
||||
case WEST -> box.minX == 0;
|
||||
case EAST -> box.maxX == 1;
|
||||
};
|
||||
}
|
||||
|
||||
/** Counterclockwise exterior faces, matching the native sequential QUADS index order. */
|
||||
private static void face(BufferBuilder out, AABB box, int x, int y, int z, Direction direction, int color) {
|
||||
float x0 = x + (float) box.minX, x1 = x + (float) box.maxX;
|
||||
float y0 = y + (float) box.minY, y1 = y + (float) box.maxY;
|
||||
float z0 = z + (float) box.minZ, z1 = z + (float) box.maxZ;
|
||||
switch (direction) {
|
||||
case DOWN -> {
|
||||
out.addVertex(x0, y0, z0).setColor(color); out.addVertex(x1, y0, z0).setColor(color);
|
||||
out.addVertex(x1, y0, z1).setColor(color); out.addVertex(x0, y0, z1).setColor(color);
|
||||
}
|
||||
case UP -> {
|
||||
out.addVertex(x0, y1, z0).setColor(color); out.addVertex(x0, y1, z1).setColor(color);
|
||||
out.addVertex(x1, y1, z1).setColor(color); out.addVertex(x1, y1, z0).setColor(color);
|
||||
}
|
||||
case NORTH -> {
|
||||
out.addVertex(x0, y0, z0).setColor(color); out.addVertex(x0, y1, z0).setColor(color);
|
||||
out.addVertex(x1, y1, z0).setColor(color); out.addVertex(x1, y0, z0).setColor(color);
|
||||
}
|
||||
case SOUTH -> {
|
||||
out.addVertex(x0, y0, z1).setColor(color); out.addVertex(x1, y0, z1).setColor(color);
|
||||
out.addVertex(x1, y1, z1).setColor(color); out.addVertex(x0, y1, z1).setColor(color);
|
||||
}
|
||||
case WEST -> {
|
||||
out.addVertex(x0, y0, z0).setColor(color); out.addVertex(x0, y0, z1).setColor(color);
|
||||
out.addVertex(x0, y1, z1).setColor(color); out.addVertex(x0, y1, z0).setColor(color);
|
||||
}
|
||||
case EAST -> {
|
||||
out.addVertex(x1, y0, z0).setColor(color); out.addVertex(x1, y1, z0).setColor(color);
|
||||
out.addVertex(x1, y1, z1).setColor(color); out.addVertex(x1, y0, z1).setColor(color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record Bounds(int minX, int maxX, int minY, int maxY, int minZ, int maxZ) {
|
||||
boolean contains(int x, int y, int z) {
|
||||
return x >= minX && x <= maxX && y >= minY && y <= maxY && z >= minZ && z <= maxZ;
|
||||
}
|
||||
|
||||
boolean onBoundary(Section section) {
|
||||
return section.x == minX || section.x == maxX || section.y == minY || section.y == maxY
|
||||
|| section.z == minZ || section.z == maxZ;
|
||||
}
|
||||
}
|
||||
|
||||
private record Shape(AABB[] boxes, int fullFaces) { }
|
||||
private static final Shape LEAVES = new Shape(new AABB[]{new AABB(0, 0, 0, 1, 1, 1)}, 0);
|
||||
|
||||
private static final class Section {
|
||||
final int x, y, z;
|
||||
final LevelChunk chunk;
|
||||
final LevelChunkSection storage;
|
||||
Entry entry;
|
||||
boolean dirty = true;
|
||||
boolean deferred;
|
||||
|
||||
Section(int x, int y, int z, LevelChunk chunk, LevelChunkSection storage) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.z = z;
|
||||
this.chunk = chunk;
|
||||
this.storage = storage;
|
||||
}
|
||||
|
||||
double distanceSquared(Vec3 camera) {
|
||||
double dx = x * 16.0 + 8 - camera.x, dy = y * 16.0 + 8 - camera.y, dz = z * 16.0 + 8 - camera.z;
|
||||
return dx * dx + dy * dy + dz * dz;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Build implements AutoCloseable {
|
||||
final Section section;
|
||||
final ByteBufferBuilder bytes = new ByteBufferBuilder(64 * 1024);
|
||||
final BufferBuilder vertices = new BufferBuilder(bytes, PrimitiveTopology.QUADS, DefaultVertexFormat.POSITION_COLOR);
|
||||
int cursor, quads;
|
||||
boolean oversized;
|
||||
|
||||
Build(Section section) {
|
||||
this.section = section;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
bytes.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,13 @@ public final class VanillaLight {
|
||||
||FabricLoader.getInstance().isModLoaded("oculus")||FabricLoader.getInstance().isModLoaded("canvas");
|
||||
private static Preferences preferences;
|
||||
private VanillaLight() { }
|
||||
private static final class Preferences { boolean enabled=true;int saturation=142; }
|
||||
private static final class Preferences {
|
||||
boolean enabled=true;
|
||||
int saturation=142;
|
||||
boolean pixelShadows=false;
|
||||
int shadowPixels=16;
|
||||
int shadowDistance=32;
|
||||
}
|
||||
private static Path path(){return FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");}
|
||||
public static boolean enabled(){
|
||||
if(preferences==null){
|
||||
@@ -22,17 +28,30 @@ public final class VanillaLight {
|
||||
catch(Exception e){SanctuaryMod.LOGGER.warn("Cannot read Sanctuary shader preference",e);}
|
||||
if(preferences==null)preferences=new Preferences();
|
||||
preferences.saturation=Math.clamp(preferences.saturation,0,200);
|
||||
preferences.shadowPixels=normalizeShadowValue(preferences.shadowPixels,8,16,32);
|
||||
preferences.shadowDistance=normalizeShadowValue(preferences.shadowDistance,16,32,64);
|
||||
}
|
||||
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 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 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 void save(){
|
||||
try{
|
||||
Files.createDirectories(path().getParent());var temporary=path().resolveSibling(path().getFileName()+".tmp");
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package fr.koka.sanctuary.mixin.client;
|
||||
|
||||
import fr.koka.sanctuary.client.shader.PixelShadows;
|
||||
import net.minecraft.client.renderer.extract.LevelExtractor;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
/** Geometry changes only: lighting-only section invalidations do not rebuild shadow casters. */
|
||||
@Mixin(LevelExtractor.class)
|
||||
abstract class PixelShadowDirtyMixin {
|
||||
@Inject(method="blockChanged", at=@At("HEAD"))
|
||||
private void sanctuary$dirty(BlockPos pos, int flags, CallbackInfo ci) {
|
||||
PixelShadows.dirtyBlock(pos);
|
||||
}
|
||||
@Inject(method="allChanged", at=@At("HEAD"))
|
||||
private void sanctuary$reload(CallbackInfo ci) { PixelShadows.release(); }
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package fr.koka.sanctuary.mixin.client;
|
||||
|
||||
import fr.koka.sanctuary.client.shader.PixelShadows;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(ClientLevel.class)
|
||||
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);
|
||||
}
|
||||
|
||||
@Inject(method="onChunkLoaded", at=@At("TAIL"))
|
||||
private void sanctuary$loaded(ChunkPos pos, CallbackInfo ci) { PixelShadows.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()); }
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package fr.koka.sanctuary.mixin.client;
|
||||
|
||||
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 net.minecraft.client.renderer.GameRenderer;
|
||||
import net.minecraft.client.renderer.ProjectionMatrixBuffer;
|
||||
import org.joml.Matrix4f;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(GameRenderer.class)
|
||||
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);
|
||||
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(); }
|
||||
@Inject(method={"close", "setLevel"}, at=@At("HEAD"))
|
||||
private void sanctuary$close(CallbackInfo ci) { PixelShadows.release(); }
|
||||
}
|
||||
@@ -1285,7 +1285,7 @@
|
||||
"sanctuary.shaders.saturation": "Saturation",
|
||||
"sanctuary.shaders.saturation_value": "Saturation: %s%%",
|
||||
"sanctuary.shaders.saturation_help": "0%: grayscale. 100%: neutral. 142%: default setting. 200%: more vivid colours. Requires Vanilla Light; interface colours stay unchanged.",
|
||||
"sanctuary.shaders.help": "Subtle colour grading and progressive fog from your viewpoint to the visible limit. No extra shadows or reflections.",
|
||||
"sanctuary.shaders.help": "Subtle colour grading and progressive fog from your viewpoint to the visible limit. Optional pixel sun shadows, with no extra reflections.",
|
||||
"sanctuary.shaders.external": "Sanctuary Vanilla Light is suspended while another shader engine is installed. Your preference is kept.",
|
||||
"sanctuary.cosmetic.bed_other": "Decorative bed: you cannot sleep in a worn bed.",
|
||||
"block.sanctuary.slime_springboard": "Temporary slime",
|
||||
@@ -2256,5 +2256,13 @@
|
||||
"block.sanctuary.sapphire_ore": "Sapphire Ore",
|
||||
"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.discovery.invalid": "Choose a discoverable block, item or species.",
|
||||
"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.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_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."
|
||||
}
|
||||
|
||||
@@ -1285,7 +1285,7 @@
|
||||
"sanctuary.shaders.saturation": "Saturation",
|
||||
"sanctuary.shaders.saturation_value": "Saturation : %s %%",
|
||||
"sanctuary.shaders.saturation_help": "0 % : noir et blanc. 100 % : neutre. 142 % : réglage par défaut. 200 % : couleurs plus vives. Avec Vanilla Light activé ; l’interface garde ses couleurs.",
|
||||
"sanctuary.shaders.help": "Couleurs légèrement retravaillées et brume progressive depuis ton point de vue jusqu’à la limite visible. Sans ombres ni reflets supplémentaires.",
|
||||
"sanctuary.shaders.help": "Couleurs légèrement retravaillées et brume progressive depuis ton point de vue jusqu’à la limite visible. Ombres pixelisées du soleil en option, sans reflets supplémentaires.",
|
||||
"sanctuary.shaders.external": "Sanctuary Vanilla Light est suspendu lorsqu’un autre moteur de shaders est installé. Votre préférence est conservée.",
|
||||
"sanctuary.cosmetic.bed_other": "Lit décoratif : impossible de dormir dans un lit porté.",
|
||||
"block.sanctuary.slime_springboard": "Slime éphémère",
|
||||
@@ -2256,5 +2256,13 @@
|
||||
"block.sanctuary.sapphire_ore": "Minerai de saphir",
|
||||
"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.discovery.invalid": "Choisis un bloc, un objet ou une espèce découvrable.",
|
||||
"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.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_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."
|
||||
}
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
#version 330
|
||||
#extension GL_ARB_separate_shader_objects : require
|
||||
layout(location = 0) in vec3 foliagePosition;
|
||||
layout(location = 1) flat in float foliage;
|
||||
layout(location = 0) out vec4 material;
|
||||
uint mixBits(uint h) {
|
||||
h ^= h >> 16; h *= 0x7feb352du; h ^= h >> 15; h *= 0x846ca68bu; return h ^ (h >> 16);
|
||||
}
|
||||
void main() {
|
||||
// Fixed quarter-block cells: no frame/time/camera seed, texture-alpha sampling or CPU ray march.
|
||||
// Each crossed leaf surface transmits 78%; independent layers converge towards solid shade.
|
||||
if (foliage > 0.5) {
|
||||
uvec3 p = uvec3(ivec3(floor((foliagePosition + 0.03125) * 4.0)));
|
||||
uint h = mixBits(p.x ^ mixBits(p.y + 0x9e3779b9u) ^ mixBits(p.z + 0x85ebca6bu));
|
||||
if (float(h & 65535u) / 65535.0 >= 0.22) discard;
|
||||
}
|
||||
material = vec4(foliage, 0.0, 0.0, 1.0);
|
||||
}
|
||||
+12
@@ -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 = 0) out vec3 foliagePosition;
|
||||
layout(location = 1) flat out float foliage;
|
||||
void main() {
|
||||
gl_Position = ModelViewMat * vec4(Position, 1.0);
|
||||
foliagePosition = Position + ModelOffset;
|
||||
foliage = Color.r;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
#version 330
|
||||
#extension GL_ARB_separate_shader_objects : require
|
||||
uniform sampler2D WorldDepth;
|
||||
uniform sampler2D SunDepth;
|
||||
uniform sampler2D SunMaterial;
|
||||
layout(std140) uniform PixelShadows {
|
||||
mat4 InverseViewProjection;
|
||||
mat4 LightMatrix;
|
||||
vec4 CameraFractionPixels;
|
||||
vec4 SunStrength;
|
||||
vec4 DistanceClipFog;
|
||||
vec4 EnvironmentFog;
|
||||
};
|
||||
layout(location = 0) in vec2 texCoord;
|
||||
layout(location = 0) out vec4 fragColor;
|
||||
float linearFog(float d, float start, float end) {
|
||||
return clamp((d - start) / max(end - start, 0.001), 0.0, 1.0);
|
||||
}
|
||||
float shadowAt(vec2 uv, vec3 receiver, vec2 planeSlope, bool includeOpaque) {
|
||||
if (!includeOpaque && texture(SunMaterial, uv).r < 0.5) return 0.0;
|
||||
// Compare each tap on the same receiving plane; an oblique floor must not shadow itself.
|
||||
float depth = receiver.z + dot(uv - receiver.xy, planeSlope);
|
||||
return depth > texture(SunDepth, uv).r + 0.00015 ? 1.0 : 0.0;
|
||||
}
|
||||
float bilinearShadow(vec2 uv, vec3 receiver, vec2 planeSlope, bool includeOpaque) {
|
||||
vec2 size = vec2(textureSize(SunDepth, 0));
|
||||
vec2 at = uv * size - 0.5;
|
||||
vec2 base = (floor(at) + 0.5) / size;
|
||||
vec2 t = fract(at), stepSize = 1.0 / size;
|
||||
return mix(mix(shadowAt(base,receiver,planeSlope,includeOpaque),
|
||||
shadowAt(base+vec2(stepSize.x,0.0),receiver,planeSlope,includeOpaque),t.x),
|
||||
mix(shadowAt(base+vec2(0.0,stepSize.y),receiver,planeSlope,includeOpaque),
|
||||
shadowAt(base+stepSize,receiver,planeSlope,includeOpaque),t.x),t.y);
|
||||
}
|
||||
float filteredShadow(vec3 coord, vec2 planeSlope, bool includeOpaque) {
|
||||
// Four fixed bilinear taps span a quarter block, averaging leaf coverage into softer shades.
|
||||
// The result is still evaluated once per world-grid cell, with no temporal/random jitter.
|
||||
float r = 0.125 / (2.0 * (DistanceClipFog.x + 16.0));
|
||||
return 0.25 * (bilinearShadow(coord.xy+vec2(-r,-r),coord,planeSlope,includeOpaque)
|
||||
+ bilinearShadow(coord.xy+vec2( r,-r),coord,planeSlope,includeOpaque)
|
||||
+ bilinearShadow(coord.xy+vec2(-r, r),coord,planeSlope,includeOpaque)
|
||||
+ bilinearShadow(coord.xy+vec2( r, r),coord,planeSlope,includeOpaque));
|
||||
}
|
||||
void main() {
|
||||
float depth = texture(WorldDepth, texCoord).r;
|
||||
float clipDepth = DistanceClipFog.y > 0.5 ? depth : depth * 2.0 - 1.0;
|
||||
vec4 projected = InverseViewProjection * vec4(texCoord * 2.0 - 1.0, clipDepth, 1.0);
|
||||
vec3 relative = projected.xyz / projected.w;
|
||||
// Derivatives before divergent branches; keep the receiving plane instead of snapping into a block.
|
||||
vec3 normal = normalize(cross(dFdx(relative), dFdy(relative)));
|
||||
if (dot(normal, -relative) < 0.0) normal = -normal;
|
||||
if (depth <= 0.0 || length(relative) >= DistanceClipFog.x) discard;
|
||||
// Depth derivatives vary with the camera. Use a stable axis for voxel faces.
|
||||
vec3 absoluteNormal = abs(normal);
|
||||
vec3 axis = absoluteNormal.x > absoluteNormal.y && absoluteNormal.x > absoluteNormal.z ? vec3(1,0,0)
|
||||
: absoluteNormal.y > absoluteNormal.z ? vec3(0,1,0) : vec3(0,0,1);
|
||||
bool voxelFace = max(max(absoluteNormal.x, absoluteNormal.y), absoluteNormal.z) > 0.995;
|
||||
if (voxelFace) normal = axis * sign(dot(normal,axis));
|
||||
vec3 p = relative + CameraFractionPixels.xyz;
|
||||
float pixels = CameraFractionPixels.w;
|
||||
vec3 snapped = (floor(p * pixels + 0.0001) + 0.5) / pixels;
|
||||
// Vanilla voxel faces live on the 1/16 grid. Quantize their plane as well as the two face axes.
|
||||
float plane = floor(dot(p,axis) * 16.0 + 0.5) / 16.0;
|
||||
vec3 q = voxelFace ? snapped + axis * (plane - dot(snapped,axis))
|
||||
: snapped + normal * dot(p - snapped, normal);
|
||||
q += normal * 0.04 + SunStrength.xyz * 0.015;
|
||||
vec4 light = LightMatrix * vec4(q, 1.0);
|
||||
vec3 coord = light.xyz / light.w;
|
||||
coord.xy = coord.xy * 0.5 + 0.5;
|
||||
if (DistanceClipFog.y < 0.5) coord.z = coord.z * 0.5 + 0.5;
|
||||
if (any(lessThan(coord, vec3(0.0))) || any(greaterThan(coord, vec3(1.0)))) discard;
|
||||
// Cofactors transform the receiver normal without a per-fragment matrix inverse.
|
||||
vec3 a = LightMatrix[0].xyz, b = LightMatrix[1].xyz, c = LightMatrix[2].xyz;
|
||||
vec3 lightNormal = normal.x * cross(b,c) + normal.y * cross(c,a) + normal.z * cross(a,b);
|
||||
vec2 planeSlope = abs(lightNormal.z) > 0.00000001
|
||||
? -lightNormal.xy / lightNormal.z * (DistanceClipFog.y > 0.5 ? 2.0 : 1.0) : vec2(0.0);
|
||||
bool leaf = texture(SunMaterial, coord.xy).r > 0.5;
|
||||
float hardShadow = shadowAt(coord.xy, coord, planeSlope, true);
|
||||
// Keep opaque-block edges crisp. Only foliage coverage receives the soft kernel.
|
||||
// Leaf taps may include opaque occluders behind them, preserving shade under roofs.
|
||||
float shade = !leaf && hardShadow > 0.5 ? 1.0 : filteredShadow(coord, planeSlope, leaf);
|
||||
float fade = 1.0 - smoothstep(DistanceClipFog.x * 0.8, DistanceClipFog.x, length(relative));
|
||||
float fog = max(linearFog(length(relative), EnvironmentFog.x, EnvironmentFog.y),
|
||||
linearFog(max(length(relative.xz), abs(relative.y)), DistanceClipFog.z, DistanceClipFog.w));
|
||||
fragColor = vec4(0.0, 0.0, 0.0, shade * SunStrength.w * fade * (1.0 - fog * EnvironmentFog.z));
|
||||
}
|
||||
@@ -274,7 +274,10 @@
|
||||
"client.SuperPistonSyncMixin",
|
||||
"client.SeasonalPrecipitationMixin",
|
||||
"client.SpecialModelsAccess",
|
||||
"client.SculptureParticlesMixin"
|
||||
"client.SculptureParticlesMixin",
|
||||
"client.PixelShadowRendererMixin",
|
||||
"client.PixelShadowLevelMixin",
|
||||
"client.PixelShadowDirtyMixin"
|
||||
],
|
||||
"injectors": {
|
||||
"defaultRequire": 1
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
name = "Sanctuary"
|
||||
author = "KOKA99CAB"
|
||||
version = "beta.126"
|
||||
version = "beta.128"
|
||||
pack-format = "packwiz:1.1.0"
|
||||
|
||||
[index]
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Shader packs
|
||||
|
||||
Emplacement des sources et réglages propres à Sanctuary. Aucun shader n'est
|
||||
encore distribué. Les shaders externes seront ajoutés au manifeste packwiz
|
||||
avec version, URL, hash, licence et compatibilité vérifiée pour le jeu et Iris.
|
||||
Le shader natif **Sanctuary · Vanilla Light** est intégré au mod. La beta.127
|
||||
ajoute les ombres portées pixélisées optionnelles, configurables depuis
|
||||
**Options → Shaders**. Sources Java et GLSL dans `mods/sanctuary/` ;
|
||||
[réglages, limites et validation](../docs/shader-beta127.md).
|
||||
|
||||
Ce dossier reste réservé aux futurs packs externes. Leur ajout au manifeste
|
||||
packwiz nécessitera version, URL, hash, licence et compatibilité vérifiée pour
|
||||
la version exacte du jeu et du moteur Iris.
|
||||
|
||||
Reference in New Issue
Block a user