diff --git a/README.md b/README.md index 3c8d198..d9fb470 100644 --- a/README.md +++ b/README.md @@ -20,15 +20,12 @@ 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 release est publiée -et l’instance **Sanctuary Beta** synchronisée, sauvegardes et réglages conservés. -Packs normal/Test et template sont aussi dans le dossier `build/`. +La version **beta.127** intègre les ombres portées pixélisées au shader natif +**Sanctuary · Vanilla Light**. Elles s'activent dans **Options → Shaders**, avec +finesse et portée réglables. Le [ticket de livraison](docs/shader-beta127.md) +décrit les réglages, les limites et les vérifications. Les ajouts de +[beta.126](docs/gems-beta126.md) — gemmes, argile spéciale et commandes de +découverte — sont conservés. La [livraison précédente](docs/clay-workshop-integration-beta110.md) réunit déjà l’atelier, les œufs et la neige saisonnière dans l’instance **Sanctuary Beta**. Les [synchronisations antérieures](docs/git-sync-beta092.md) restent documentées. @@ -39,6 +36,14 @@ La [carte native beta.005](docs/atlas-beta005.md) prend le relais après l’étude de Xaero et des alternatives libres. +## 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, diff --git a/docs/pixel-shadows-source122.md b/docs/pixel-shadows-source122.md new file mode 100644 index 0000000..3c4fa3b --- /dev/null +++ b/docs/pixel-shadows-source122.md @@ -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. diff --git a/docs/shader-beta127.md b/docs/shader-beta127.md new file mode 100644 index 0000000..c462f5f --- /dev/null +++ b/docs/shader-beta127.md @@ -0,0 +1,73 @@ +# 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` | diff --git a/docs/shaders-beta040.md b/docs/shaders-beta040.md index 2d91000..1e1204a 100644 --- a/docs/shaders-beta040.md +++ b/docs/shaders-beta040.md @@ -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. diff --git a/gradle.properties b/gradle.properties index 864022f..992f3cd 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,8 +9,8 @@ loom_version=1.17.20 fabric_api_version=0.160.5+26.3 # Release counter: beta.001, .002, .003, ... (see docs/versioning.md). -mod_version=beta.126 -pack_version=beta.126 +mod_version=beta.127 +pack_version=beta.127 resource_pack_version=beta.121 maven_group=fr.koka.sanctuary jei_version=30.32.0-sanctuary.3 diff --git a/mods/sanctuary/build.gradle b/mods/sanctuary/build.gradle index be6d2ca..2c0fbd0 100644 --- a/mods/sanctuary/build.gradle +++ b/mods/sanctuary/build.gradle @@ -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') } diff --git a/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/shader/PixelShadows122ClientChecks.java b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/shader/PixelShadows122ClientChecks.java new file mode 100644 index 0000000..41d5e92 --- /dev/null +++ b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/shader/PixelShadows122ClientChecks.java @@ -0,0 +1,477 @@ +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"); + var level = s.overworld(); + // 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"); + + server.runOnServer(s -> command(s, "time set midnight")); + c.waitFor(m -> PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle).y < 0, + 200); + c.waitTicks(10); + int nightFrames = c.computeOnClient(m -> { + check(VanillaLight.pixelShadows() && !PixelShadows.applied(), "Enabled sun shadows are inactive at midnight"); + return PixelShadows.renderedFrames(); + }); + var nightOn = capture(c, "midnight-on"); + c.waitTicks(6); + c.runOnClient(m -> check(PixelShadows.renderedFrames() == nightFrames, "Midnight submits no sun-shadow frames")); + shadows(c, false); + unchanged(nightOn, capture(c, "midnight-off"), "Midnight ON/OFF image is unchanged"); + + server.runOnServer(s -> command(s, "time set 3000")); + 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-beta122-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; midnight idle; 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 java.util.stream.Stream 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(); + p.teleportTo(x, y, z); + p.setDeltaMovement(Vec3.ZERO); + }); + c.waitFor(m -> m.player.position().distanceToSqr(new Vec3(x, y, z)) < .0001, 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 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-beta122-shadows-" + name); + var result = new CompletableFuture(); + 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); } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/SanctuaryKnowledgeClient.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/SanctuaryKnowledgeClient.java index fb1feeb..b578d51 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/SanctuaryKnowledgeClient.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/SanctuaryKnowledgeClient.java @@ -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(); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/PixelShadows.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/PixelShadows.java new file mode 100644 index 0000000..cdd3cc2 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/PixelShadows.java @@ -0,0 +1,170 @@ +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; + +/** Native sun 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) + .withPrimitiveTopology(PrimitiveTopology.QUADS).withCull(false) + .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).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); + if (sky.skybox != DimensionType.Skybox.OVERWORLD || camera.fogType != FogType.NONE || sun.y <= .08f) return; + float strength = .38f * 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 sun shadows", size, size, null, GpuFormat.D32_FLOAT); + } + if (uniforms == null) uniforms = new MappableRingBuffer(() -> "Sanctuary pixel shadows", + GpuBuffer.USAGE_UNIFORM | GpuBuffer.USAGE_MAP_WRITE, 192); + double ox = Math.floor(camera.pos.x), oy = Math.floor(camera.pos.y), oz = Math.floor(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(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)))); + 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 sun depth") + .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.draw(3, 1, 0, 0); + } + renderedFrames++; + applied = true; + } + + 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 + 8) * 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 + 8, 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; } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/ShaderOptionsScreen.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/ShaderOptionsScreen.java index e6b0244..200374e 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/ShaderOptionsScreen.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/ShaderOptionsScreen.java @@ -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("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("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("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)); } } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/ShadowTerrain.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/ShadowTerrain.java new file mode 100644 index 0000000..f1e17fd --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/ShadowTerrain.java @@ -0,0 +1,512 @@ +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.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 DefaultVertexFormat.POSITION (three floats), 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. + * + *

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 chunks = new HashMap<>(); + private final Map sections = new HashMap<>(); + private final Map shapes = new IdentityHashMap<>(); + private ClientLevel level; + private Bounds bounds; + private Build building; + private long gpuBytes; + private List 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. + * + *

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 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 entries() { + RenderSystem.assertOnRenderThread(); + if (entriesChanged) { + var result = new ArrayList(); + 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(); + 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

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; + Shape shape = shape(build.section.storage.getBlockState(x, y, z)); + if (shape != null) { + if (shape.boxes == null) { + build.oversized = true; + return true; + } + int hidden = 0; + for (Direction direction : DIRECTIONS) { + 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); + 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.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(); + 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) { + 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); out.addVertex(x1, y0, z0); + out.addVertex(x1, y0, z1); out.addVertex(x0, y0, z1); + } + case UP -> { + out.addVertex(x0, y1, z0); out.addVertex(x0, y1, z1); + out.addVertex(x1, y1, z1); out.addVertex(x1, y1, z0); + } + case NORTH -> { + out.addVertex(x0, y0, z0); out.addVertex(x0, y1, z0); + out.addVertex(x1, y1, z0); out.addVertex(x1, y0, z0); + } + case SOUTH -> { + out.addVertex(x0, y0, z1); out.addVertex(x1, y0, z1); + out.addVertex(x1, y1, z1); out.addVertex(x0, y1, z1); + } + case WEST -> { + out.addVertex(x0, y0, z0); out.addVertex(x0, y0, z1); + out.addVertex(x0, y1, z1); out.addVertex(x0, y1, z0); + } + case EAST -> { + out.addVertex(x1, y0, z0); out.addVertex(x1, y1, z0); + out.addVertex(x1, y1, z1); out.addVertex(x1, y0, z1); + } + } + } + + 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 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); + int cursor, quads; + boolean oversized; + + Build(Section section) { + this.section = section; + } + + @Override + public void close() { + bytes.close(); + } + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/VanillaLight.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/VanillaLight.java index 58e2e98..46a5e4e 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/VanillaLight.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/VanillaLight.java @@ -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"); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/PixelShadowDirtyMixin.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/PixelShadowDirtyMixin.java new file mode 100644 index 0000000..7789297 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/PixelShadowDirtyMixin.java @@ -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(); } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/PixelShadowLevelMixin.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/PixelShadowLevelMixin.java new file mode 100644 index 0000000..da97b03 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/PixelShadowLevelMixin.java @@ -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()); } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/PixelShadowRendererMixin.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/PixelShadowRendererMixin.java new file mode 100644 index 0000000..54921aa --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/PixelShadowRendererMixin.java @@ -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 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(); } +} diff --git a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json index 2f6615b..7d9a0ec 100644 --- a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json +++ b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json @@ -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 cast by solid blocks, aligned to a pixel grid. Disabled by default. Requires Vanilla Light; gameplay light levels stay unchanged.", + "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." } diff --git a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json index 98f00b3..a1541de 100644 --- a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json +++ b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json @@ -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 projetées par les blocs solides, alignées sur une grille de pixels. Désactivées par défaut. Nécessite Vanilla Light ; ne change pas la lumière du jeu.", + "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." } diff --git a/mods/sanctuary/src/main/resources/assets/sanctuary/shaders/core/pixel_shadow_depth.fsh b/mods/sanctuary/src/main/resources/assets/sanctuary/shaders/core/pixel_shadow_depth.fsh new file mode 100644 index 0000000..d3ac7a1 --- /dev/null +++ b/mods/sanctuary/src/main/resources/assets/sanctuary/shaders/core/pixel_shadow_depth.fsh @@ -0,0 +1,3 @@ +#version 330 +#extension GL_ARB_separate_shader_objects : require +void main() { } diff --git a/mods/sanctuary/src/main/resources/assets/sanctuary/shaders/core/pixel_shadow_depth.vsh b/mods/sanctuary/src/main/resources/assets/sanctuary/shaders/core/pixel_shadow_depth.vsh new file mode 100644 index 0000000..711bb7a --- /dev/null +++ b/mods/sanctuary/src/main/resources/assets/sanctuary/shaders/core/pixel_shadow_depth.vsh @@ -0,0 +1,7 @@ +#version 330 +#extension GL_ARB_separate_shader_objects : require +#include +layout(location = 0) in vec3 Position; +void main() { + gl_Position = ModelViewMat * vec4(Position, 1.0); +} diff --git a/mods/sanctuary/src/main/resources/assets/sanctuary/shaders/core/pixel_shadows.fsh b/mods/sanctuary/src/main/resources/assets/sanctuary/shaders/core/pixel_shadows.fsh new file mode 100644 index 0000000..660fe95 --- /dev/null +++ b/mods/sanctuary/src/main/resources/assets/sanctuary/shaders/core/pixel_shadows.fsh @@ -0,0 +1,43 @@ +#version 330 +#extension GL_ARB_separate_shader_objects : require +uniform sampler2D WorldDepth; +uniform sampler2D SunDepth; +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); +} +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; + vec3 p = relative + CameraFractionPixels.xyz; + float pixels = CameraFractionPixels.w; + vec3 snapped = (floor(p * pixels) + 0.5) / pixels; + vec3 q = 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; + float blocker = texture(SunDepth, coord.xy).r; + float shade = coord.z > blocker + 0.00015 ? 1.0 : 0.0; + 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)); +} diff --git a/mods/sanctuary/src/main/resources/sanctuary.mixins.json b/mods/sanctuary/src/main/resources/sanctuary.mixins.json index 74b68ae..f0c4366 100644 --- a/mods/sanctuary/src/main/resources/sanctuary.mixins.json +++ b/mods/sanctuary/src/main/resources/sanctuary.mixins.json @@ -274,7 +274,10 @@ "client.SuperPistonSyncMixin", "client.SeasonalPrecipitationMixin", "client.SpecialModelsAccess", - "client.SculptureParticlesMixin" + "client.SculptureParticlesMixin", + "client.PixelShadowRendererMixin", + "client.PixelShadowLevelMixin", + "client.PixelShadowDirtyMixin" ], "injectors": { "defaultRequire": 1 diff --git a/packwiz/pack.toml b/packwiz/pack.toml index f41da2d..3336248 100644 --- a/packwiz/pack.toml +++ b/packwiz/pack.toml @@ -1,6 +1,6 @@ name = "Sanctuary" author = "KOKA99CAB" -version = "beta.126" +version = "beta.127" pack-format = "packwiz:1.1.0" [index] diff --git a/shaders-pack/README.md b/shaders-pack/README.md index 1edbf1d..c497e28 100644 --- a/shaders-pack/README.md +++ b/shaders-pack/README.md @@ -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.