feat(shader): selective bloom and shared precision in beta.130
Build Sanctuary / build (push) Canceled after 0s

This commit is contained in:
koka
2026-09-17 21:04:33 +02:00
parent 26e305d15f
commit 534dfec4f8
27 changed files with 843 additions and 32 deletions
+12 -9
View File
@@ -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 L’économie, les claims et les dimensions décrits dans la vision restent
à implémenter. à implémenter.
La version **beta.129** ajoute les reflets CTM optionnels sur les contours du La version **beta.130** ajoute un bloom réglable, les inclusions émissives des
relief, avec une portée indépendante de 16 à 256 blocs. Les surfaces plates minerais et une **Précision** commune aux ombres et au highlight. Par défaut :
se raccordent entre tous les matériaux. Les ombres gagnent les portées ombres activées à 128 blocs, highlight à 32 blocs, précision 16 pixels/bloc.
128/256, gardent leur intensité à midi et ne réalignent plus leur grille à **Options → Shaders → Sanctuary · Vanilla Light**. Les réglages personnalisés
chaque mouvement du soleil. **Options → Shaders → Sanctuary · Vanilla Light**. sont conservés. Le [ticket de livraison](docs/shader-beta130.md) précise le
Le [ticket de livraison](docs/shader-beta129.md) précise le résultat et ses résultat, les vérifications et l'état de distribution.
vérifications. La release est publiée et linstance **Sanctuary Beta** est
synchronisée, sauvegardes et réglages conservés. Packs normal/Test et template
sont disponibles dans `build/`.
La [livraison précédente](docs/clay-workshop-integration-beta110.md) réunit déjà La [livraison précédente](docs/clay-workshop-integration-beta110.md) réunit déjà
latelier, les œufs et la neige saisonnière dans linstance **Sanctuary Beta**. latelier, les œufs et la neige saisonnière dans linstance **Sanctuary Beta**.
Les [synchronisations antérieures](docs/git-sync-beta092.md) restent documentées. Les [synchronisations antérieures](docs/git-sync-beta092.md) restent documentées.
@@ -39,6 +36,12 @@ La [carte native beta.005](docs/atlas-beta005.md) prend le relais après
l’étude de Xaero et des alternatives libres. l’étude de Xaero et des alternatives libres.
## beta.130 — Bloom, minerais émissifs et précision commune
Halo réglable des sources lumineuses et inclusions des vingt textures de
minerais ; réglages par défaut 32/128/16 et ombres activées.
[Contrat et vérifications](docs/shader-beta130.md).
## beta.129 — Reflets CTM et ombres jusqu'à 256 blocs ## beta.129 — Reflets CTM et ombres jusqu'à 256 blocs
Reflets connectés réglables, cinq portées indépendantes, soleil plein à midi Reflets connectés réglables, cinq portées indépendantes, soleil plein à midi
+118
View File
@@ -0,0 +1,118 @@
# SHADER-130 — bloom et précision commune
Demandes du 17 septembre 2026 : bloom réglable du soleil et des sources
lumineuses ; inclusions émissives du fer, cuivre, redstone, lapis, diamant,
émeraude, rubis, saphir, quartz et or ; réglage commun « Précision » pour
les ombres et le highlight. Branche `codex/bloom-beta130`, socle beta.129,
Minecraft 26.3 / Java 25.
## Résultat
Dans **Options → Shaders → Sanctuary · Vanilla Light**, le bloom possède
un interrupteur, une intensité et une diffusion réglables de 0 à 100.
Les minerais ont leur propre interrupteur et intensité. Le bloom reste
optionnel, désactivé initialement, avec une intensité préparée à 35, une
diffusion à 50 et une émission des minerais à 50. Une intensité globale
nulle arrête les passes et libère les ressources de cet effet.
Les nouveaux réglages par défaut sont **Pixel Shadows activé**, distance
des ombres **128 blocs**, highlight **32 blocs** et **Précision 16 pixels
par bloc**. La précision unique propose 8, 16 et 32 et contrôle simultanément
ombres et arêtes. La clé JSON historique `shadowPixels` est conservée :
les préférences déjà personnalisées ne sont pas écrasées.
Les vingt textures de minerais sont analysées depuis les ressources actives.
La palette de pierre, de deepslate ou de netherrack est comparée aux pixels
colorés des inclusions, avec un traitement du quartz et de l'or du Nether.
Les reflets clairs accolés aux inclusions sont conservés. Le masque est
calculé une fois par texture et conservé en cache jusqu'au rechargement des
ressources. Une texture optionnelle `<nom>_e.png` peut préciser ce masque
pour une texture candidate ; sa luminance maximale multipliée par l'alpha
remplace la détection automatique. Les couleurs affichées viennent toujours
de l'atlas Minecraft actif, y compris son animation.
Les sources sont les faces visibles des modèles de blocs lumineux, la lave,
le soleil et la lune. Les lampes éteintes ne produisent pas de halo. Le
niveau lumineux natif pondère les sources ordinaires ; l'émission des minerais
est purement graphique. La profondeur du terrain élimine les inclusions
cachées et masque le soleil derrière les constructions. Le cœur émissif
conserve ses pixels nets, tandis qu'un halo séparé est diffusé à résolution
réduite puis composé sur l'image.
Aucun niveau de lumière serveur, spawn, règle de jeu, format de sauvegarde,
identifiant ou paramètre de génération ne change. Aucun monde personnel
n'est ouvert pour les essais.
## Coût et limites
La géométrie suit uniquement les sections natives visibles et déjà chargées.
Leur palette permet d'écarter rapidement les sections sans source ; le cache
invalide les sections touchées lors des modifications. Construction répartie
sur les images, au plus quatre sections démarrées et un budget souple de
3 ms, cache GPU plafonné à 32 Mio et 16 384 faces par section. Une section
qui dépasse les limites est omise. Aucun chargement de chunk forcé.
Le masque GPU est plafonné à 256 textures, chacune échantillonnée au plus en
64 × 64 ; les textures natives 16 × 16 conservent leurs texels. Les masques
animés utilisent leur première image, tandis que la couleur reste animée.
La carte d'émission est à la résolution de l'écran ; la diffusion séparable
utilise deux cibles flottantes à 1/8 de largeur et de hauteur, sans retour
GPU vers CPU en production. Les allocations sont libérées à l'arrêt, au
rechargement et à la déconnexion, et recréées au changement de taille.
Cette première version ne reconstruit pas les sources hors écran, les
particules, les entités lumineuses ni les modèles dessinés exclusivement par
un renderer de block entity. Les packs très différents peuvent demander un
masque explicite. Les silhouettes des liquides sont approximées puis
masquées par la profondeur native. Aucune promesse de FPS ni validation
Windows/Vulkan ; les essais graphiques portent sur macOS / Apple M1 / OpenGL.
## Vérifications
Le test client ciblé réussit en **1 min 29 s**, dans un nouveau monde plat,
graine 122. Il analyse les vingt textures, refuse les pixels gris de leur
roche, mesure le gain nocturne et les variations réelles d'intensité/diffusion.
Couvrir les minerais de pierre supprime toute source émissive. Retirer les
blocs rétablit l'émission ; le rechargement des ressources fait de même.
Torche, glowstone, lampe alimentée, lave et soleil sont vérifiés, ainsi que
l'arrêt de la lampe et l'occultation du soleil par de la pierre. Les valeurs
par défaut et la conservation d'anciens choix personnalisés passent.
Log ciblé : `build/shader130-client-bloom.log` ; masques inspectables dans
`mods/sanctuary/build/run/clientGameTest/bloom130-ore-masks.png` et son CSV.
La suite shader complète réussit en **3 min 16 s** : casters hors écran,
retrait/repose, trois précisions, cinq portées, lune, zénith, feuillage,
realtime, caméra et rechargement restent vérifiés. Les arêtes CTM restent
raccordées entre matériaux ; 1 295 échantillons du contour sont éclaircis
à précision 8, 596 à 16 et 120 à 32. Les captures françaises/anglaises et
le rendu des minerais/du soleil sont inspectés.
Commandes natives : `./gradlew :sanctuary:runClientGameTest
-PsanctuaryClientTests=true -PsanctuaryPixelShadows122ClientTests=true
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true`. Le test ciblé utilise
`-PsanctuaryBloom130ClientTests=true` à la place de la propriété PixelShadows.
Log complet : `build/shader130-client-full.log` ; captures sous
`mods/sanctuary/build/run/clientGameTest/screenshots/`.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
réussit en **2 min 24 s**, 126 tâches. Le serveur GameTest dédié reste exclu
conformément au refus antérieur de son EULA. La comparaison au binaire beta.129
trouve **24 entrées de production** modifiées, toutes limitées au shader et à
ses interfaces. Gameplay et textures des gemmes inchangés ; aucun test,
monde ou modèle GLB embarqué. Les sources archivées correspondent aux sources
Java, les packs normal/Test embarquent le même JAR et le template contient
exactement les ressources prévues.
Archives vérifiées dans `build/` et recopiées dans le dossier habituel
`sanctuary-beta/build/` :
- `Sanctuary-beta.130.mrpack` : 10386021 octets, SHA-256
`7a3fcd4e496fa136eb3e0896fa7b856ae91771123186fa8df13c8fb1e0cf3e9a`.
- `Sanctuary-Test-beta.130.mrpack` : 10404944 octets, SHA-256
`8de094fcbc58a300cc24d64c8d1b7931fdb8db354c75defa6bf2a2aa4a123a23`.
JAR : `1ac4d1b1339361fb665f03ca3bd359163db5037ffd90e684c5a461759f6076e7`.
Publication et synchronisation de l'instance à effectuer après le commit source.
+2 -2
View File
@@ -9,8 +9,8 @@ loom_version=1.17.20
fabric_api_version=0.160.5+26.3 fabric_api_version=0.160.5+26.3
# Release counter: beta.001, .002, .003, ... (see docs/versioning.md). # Release counter: beta.001, .002, .003, ... (see docs/versioning.md).
mod_version=beta.129 mod_version=beta.130
pack_version=beta.129 pack_version=beta.130
resource_pack_version=beta.121 resource_pack_version=beta.121
maven_group=fr.koka.sanctuary maven_group=fr.koka.sanctuary
jei_version=30.32.0-sanctuary.3 jei_version=30.32.0-sanctuary.3
+4
View File
@@ -93,6 +93,10 @@ tasks.named('runGameTest') {
} }
tasks.named('processGametestResources') { tasks.named('processGametestResources') {
def bloom130Client = providers.gradleProperty('sanctuaryBloom130ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryBloom130ClientTests', bloom130Client)
if (bloom130Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.Bloom130ClientChecks') }
def shadows122Client = providers.gradleProperty('sanctuaryPixelShadows122ClientTests').map { it.toBoolean() }.getOrElse(false) def shadows122Client = providers.gradleProperty('sanctuaryPixelShadows122ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryPixelShadows122ClientTests', shadows122Client) inputs.property('sanctuaryPixelShadows122ClientTests', shadows122Client)
if (shadows122Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks') } if (shadows122Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks') }
@@ -0,0 +1,126 @@
package fr.koka.sanctuary.client.shader;
import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*;
import com.mojang.blaze3d.platform.NativeImage;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.client.Screenshot;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.block.Blocks;
/** Actual source texels, native GPU masks, occlusion and user controls in an isolated world. */
public final class Bloom130ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){new PixelShadows122ClientChecks(true).runTest(c);}
static final List<String> ORES=ores();
private static List<String> ores(){var result=new ArrayList<String>();for(String name:List.of("iron","copper","redstone","lapis","diamond","emerald","ruby","sapphire","gold")){
String ns=name.equals("ruby")||name.equals("sapphire")?"sanctuary:":"minecraft:";result.add(ns+name+"_ore");result.add(ns+"deepslate_"+name+"_ore");}
result.add("minecraft:nether_quartz_ore");result.add("minecraft:nether_gold_ore");return List.copyOf(result);}
static void run(ClientGameTestContext c,TestServerContext server){
c.runOnClient(m->{preferences();analyseTextures();VanillaLight.setSaturation(100);VanillaLight.setPixelShadows(false);VanillaLight.setEdgeHighlights(false);VanillaLight.setBloom(false);});
server.runOnServer(s->{command(s,"time set 18000");
for(int x=4;x<=20;x++)for(int z=4;z<=20;z++){
s.overworld().setBlockAndUpdate(new BlockPos(x,64,z),Blocks.CONCRETE.black().defaultBlockState());
for(int y=65;y<=70;y++)s.overworld().setBlockAndUpdate(new BlockPos(x,y,z),Blocks.AIR.defaultBlockState());
}
for(int i=0;i<ORES.size();i++){var state=BuiltInRegistries.BLOCK.getValue(Identifier.parse(ORES.get(i))).defaultBlockState();check(state.getLightEmission()==0,"Ores do not acquire server-side light: "+ORES.get(i));s.overworld().setBlockAndUpdate(orePos(i),state);}
s.overworld().setBlockAndUpdate(new BlockPos(14,65,9),Blocks.REDSTONE_LAMP.defaultBlockState());
});
camera(c,server,10.5,71,9.5,60);c.waitTicks(40);c.waitFor(m->m.levelRenderer.hasRenderedAllSections(),1200);
var off=capture(c,"bloom-ores-off");
c.runOnClient(m->{VanillaLight.setOreEmission(false);VanillaLight.setBloom(true);});frames(c);
check(luminous(emission(c))==0,"White/grey rock, ordinary surfaces and unlit lamps do not seed bloom");
unchanged(off,capture(c,"bloom-ores-disabled"),"Disabling ore emission leaves the original scene intact");
c.runOnClient(m->{VanillaLight.setOreEmission(true);VanillaLight.setOreIntensity(100);});frames(c);
var source=emission(c);int orePixels=luminous(source);check(orePixels>100,"Actual exposed ore inclusions enter the GPU mask: "+orePixels);
var on=capture(c,"bloom-ores-on");double oreGain=gain(off,on,null);check(oreGain>1000,"Ores visibly glow in the native night scene: "+oreGain);
c.runOnClient(m->VanillaLight.setBloomIntensity(10));frames(c);double low=gain(off,capture(c,null),source);
c.runOnClient(m->VanillaLight.setBloomIntensity(90));frames(c);double high=gain(off,capture(c,"bloom-strength-90"),source);
check(high>Math.max(30,low*2),"Bloom strength changes the halo outside source pixels: low="+low+" high="+high);
c.runOnClient(m->VanillaLight.setBloomRadius(0));frames(c);var tight=capture(c,"bloom-spread-0");
c.runOnClient(m->VanillaLight.setBloomRadius(100));frames(c);var wide=capture(c,"bloom-spread-100");
check(difference(tight,wide)>100,"Spread changes the real GPU halo");
c.runOnClient(m->VanillaLight.setOreIntensity(0));frames(c);check(luminous(emission(c))==0,"Zero ore strength suppresses both cores and halos");
unchanged(off,capture(c,null),"Zero ore strength restores the source image");
c.runOnClient(m->{VanillaLight.setOreIntensity(100);VanillaLight.setBloomIntensity(35);VanillaLight.setBloomRadius(50);});frames(c);
server.runOnServer(s->{for(int i=0;i<ORES.size();i++)s.overworld().setBlockAndUpdate(orePos(i).above(),Blocks.STONE.defaultBlockState());});c.waitTicks(25);frames(c);
check(luminous(emission(c))==0,"Covering veins with stone removes all emission: no through-wall ore glow");
capture(c,"bloom-covered-ores");
server.runOnServer(s->{for(int i=0;i<ORES.size();i++)s.overworld().setBlockAndUpdate(orePos(i).above(),Blocks.AIR.defaultBlockState());});c.waitTicks(25);frames(c);
check(luminous(emission(c))>100,"Removing covers reveals the genuine visible inclusion masks again");
var reload=c.computeOnClient(net.minecraft.client.Minecraft::reloadResourcePacks);c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();frames(c);
check(luminous(emission(c))>100,"Resource reload rebuilds emitter meshes and masks");
c.runOnClient(m->VanillaLight.setOreEmission(false));
server.runOnServer(s->s.overworld().setBlockAndUpdate(new BlockPos(14,65,9),Blocks.TORCH.defaultBlockState()));c.waitTicks(25);frames(c);
int torch=luminous(emission(c));check(torch>3,"A native torch seeds bloom: "+torch);capture(c,"bloom-torch");
server.runOnServer(s->s.overworld().setBlockAndUpdate(new BlockPos(14,65,9),Blocks.GLOWSTONE.defaultBlockState()));c.waitTicks(25);frames(c);
check(luminous(emission(c))>torch,"A full luminous block contributes its real textured surface");capture(c,"bloom-glowstone");
server.runOnServer(s->s.overworld().setBlockAndUpdate(new BlockPos(14,65,9),Blocks.STONE.defaultBlockState()));c.waitTicks(25);frames(c);
check(luminous(emission(c))==0,"Replacing the light source removes its cached emission");
server.runOnServer(s->{s.overworld().setBlockAndUpdate(new BlockPos(14,65,9),Blocks.REDSTONE_LAMP.defaultBlockState());s.overworld().setBlockAndUpdate(new BlockPos(14,64,9),Blocks.REDSTONE_BLOCK.defaultBlockState());});
c.waitTicks(25);frames(c);check(luminous(emission(c))>10,"Powered lamp emits through its on-state texture");capture(c,"bloom-lamp-on");
server.runOnServer(s->s.overworld().setBlockAndUpdate(new BlockPos(14,64,9),Blocks.CONCRETE.black().defaultBlockState()));
c.waitTicks(25);frames(c);check(luminous(emission(c))==0,"Unpowered lamp loses its glow after its native state update");
server.runOnServer(s->s.overworld().setBlockAndUpdate(new BlockPos(14,65,9),Blocks.LAVA.defaultBlockState()));
c.waitTicks(25);frames(c);check(luminous(emission(c))>10,"Native visible lava seeds bloom");capture(c,"bloom-lava");
server.runOnServer(s->command(s,"time set 3000"));camera(c,server,10.5,90,9.5,70);
var direction=c.computeOnClient(m->PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle));
look(c,server,(float)Math.toDegrees(Math.atan2(-direction.x,direction.z)),-(float)Math.toDegrees(Math.asin(direction.y)));
c.runOnClient(m->VanillaLight.setBloom(false));c.waitTicks(8);var sunOff=capture(c,"bloom-sun-off");
c.runOnClient(m->VanillaLight.setBloom(true));frames(c);var sunMask=emission(c);
check(luminous(sunMask)>40,"The actual sun disc seeds bloom");
double sunlight=gain(sunOff,capture(c,"bloom-sun-on"),sunMask);check(sunlight>100,"Sun halo extends beyond its source disc: "+sunlight);
server.runOnServer(s->{var eye=s.getPlayerList().getPlayers().getFirst().getEyePosition();var center=BlockPos.containing(eye.x+direction.x*8,eye.y+direction.y*8,eye.z+direction.z*8);
for(int x=-4;x<=4;x++)for(int y=-4;y<=4;y++)for(int z=-4;z<=4;z++)s.overworld().setBlockAndUpdate(center.offset(x,y,z),Blocks.STONE.defaultBlockState());});
c.waitTicks(30);frames(c);check(luminous(emission(c))==0,"Solid terrain in front of the sun suppresses its bloom source");capture(c,"bloom-sun-occluded");
c.runOnClient(m->VanillaLight.setBloom(false));c.waitTicks(6);var coveredOff=capture(c,null);
c.runOnClient(m->{VanillaLight.setBloom(true);VanillaLight.setBloomIntensity(0);});c.waitTicks(6);c.runOnClient(m->check(!Bloom.applied()&&!Bloom.allocated(),"Zero bloom releases GPU resources"));
unchanged(coveredOff,capture(c,null),"Zero bloom leaves no residual glow");
c.runOnClient(m->{VanillaLight.setBloomIntensity(35);VanillaLight.setBloom(false);VanillaLight.setOreEmission(true);VanillaLight.setOreIntensity(50);});
System.out.println("BLOOM130_PASS masks for 20 ore textures; selective night glow; halo intensity/spread; no through-wall glow; live edits; torch/glowstone/lamp/lava/sun; resource reload; defaults/migration; orePixels="+orePixels+" oreGain="+oreGain+" haloLow="+low+" haloHigh="+high+" sunHalo="+sunlight);
}
private static BlockPos orePos(int i){return new BlockPos(8+i%5,65,8+i/5);}
private static void preferences(){try{
var path=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");Files.deleteIfExists(path);forgetPreferences();
check(VanillaLight.pixelShadows()&&VanillaLight.shadowDistance()==128&&VanillaLight.shadowPixels()==16&&VanillaLight.edgeDistance()==32,"Fresh requested defaults: shadows ON, 128; highlights 32; shared precision 16");
check(!VanillaLight.bloom()&&VanillaLight.bloomIntensity()==35&&VanillaLight.bloomRadius()==50,"Bloom is opt-in with usable defaults");
Files.writeString(path,"{\"pixelShadows\":false,\"shadowDistance\":64,\"shadowPixels\":8,\"edgeDistance\":16}");forgetPreferences();
check(!VanillaLight.pixelShadows()&&VanillaLight.shadowDistance()==64&&VanillaLight.shadowPixels()==8&&VanillaLight.edgeDistance()==16,"Existing user choices are retained");
for(int n:new int[]{-10,0,35,100,400}){VanillaLight.setBloomIntensity(n);VanillaLight.setBloomRadius(n);VanillaLight.setOreIntensity(n);forgetPreferences();check(VanillaLight.bloomIntensity()==Math.clamp(n,0,100)&&VanillaLight.bloomRadius()==Math.clamp(n,0,100)&&VanillaLight.oreIntensity()==Math.clamp(n,0,100),"Persisted bloom controls are bounded");}
VanillaLight.setBloomIntensity(35);VanillaLight.setBloomRadius(50);VanillaLight.setOreIntensity(50);VanillaLight.setShadowPixels(16);
}catch(java.io.IOException e){throw new AssertionError(e);}}
private static void analyseTextures(){
var report=new StringBuilder("texture,selected,total,neutral_rock_selected\n");
try(var materials=new BloomMaterials();var sheet=new NativeImage(5*16*6,4*32*6,false)){
sheet.fillRect(0,0,sheet.getWidth(),sheet.getHeight(),0xFF16191E);
for(int index=0;index<ORES.size();index++){
var block=Identifier.parse(ORES.get(index));var id=block.withPath("block/"+block.getPath());
try(var image=BloomMaterials.read(id)){
int w=image.getWidth(),h=image.getHeight();int[] pixels=new int[w*h];for(int y=0;y<h;y++)for(int x=0;x<w;x++)pixels[y*w+x]=image.getPixel(x,y);
var mask=materials.mask(id,pixels,w,h,true,false);int count=0,grey=0;
for(int i=0;i<mask.length;i++){if(mask[i]>0){count++;int c=pixels[i],r=c>>16&255,g=c>>8&255,b=c&255;if(r==g&&g==b&&r<215)grey++;}}
check(count>=4&&count<w*h*.7,"Texture has a selective inclusion mask: "+id+" "+count+"/"+mask.length);
if(!id.getPath().contains("quartz"))check(grey==0,"Neutral host rock never emits: "+id);
report.append(id).append(',').append(count).append(',').append(mask.length).append(',').append(grey).append('\n');
for(int y=0;y<32*6;y++)for(int x=0;x<16*6;x++){
int source=(y/6%16)*w/16*w+(x/6)*w/16;int color=pixels[source];if(y>=16*6&&mask[source]==0)color=0xFF16191E;
sheet.setPixel((index%5)*16*6+x,(index/5)*32*6+y,color);
}
}
}
var root=FabricLoader.getInstance().getGameDir();sheet.writeToFile(root.resolve("bloom130-ore-masks.png"));Files.writeString(root.resolve("bloom130-ore-masks.csv"),report.toString());
System.out.println("BLOOM130_MASKS\n"+report);
}catch(java.io.IOException e){throw new AssertionError(e);}
}
private static void frames(ClientGameTestContext c){int before=c.computeOnClient(m->Bloom.renderedFrames());c.waitFor(m->Bloom.applied()&&Bloom.ready()&&Bloom.renderedFrames()>before+10,1200);c.waitTicks(8);}
private static Frame emission(ClientGameTestContext c){var result=new CompletableFuture<Frame>();c.runOnClient(m->Screenshot.takeScreenshot(Bloom.emissionTarget(),image->{try(image){result.complete(new Frame(image.getWidth(),image.getHeight(),image.getPixels()));}}));c.waitFor(m->result.isDone(),200);return result.join();}
private static int luminous(Frame frame){int count=0;for(int color:frame.pixels())if((color&0xFFFFFF)!=0)count++;return count;}
private static double gain(Frame off,Frame on,Frame source){double result=0;for(int i=0;i<off.pixels().length;i++)if(source==null||(source.pixels()[i]&0xFFFFFF)==0)result+=Math.max(0,brightness(on.pixels()[i])-brightness(off.pixels()[i]));return result;}
private static int difference(Frame a,Frame b){int n=0;for(int i=0;i<a.pixels().length;i++)if(channelDifference(a.pixels()[i],b.pixels()[i])>3)n++;return n;}
}
@@ -35,6 +35,14 @@ final class EdgeHighlights129ClientChecks {
check(low.brighter>100 && low.stable>low.samples*.55 && low.darker<low.samples*.005, check(low.brighter>100 && low.stable>low.samples*.55 && low.darker<low.samples*.005,
"Highlight brightens local strips, preserves face interiors: "+low); "Highlight brightens local strips, preserves face interiors: "+low);
grid(c,off,on); grid(c,off,on);
c.runOnClient(m -> VanillaLight.setShadowPixels(8));frames(c);
var coarse=measure(off,capture(c,"edges-shared-precision-8"));
c.runOnClient(m -> VanillaLight.setShadowPixels(32));frames(c);
var fine=measure(off,capture(c,"edges-shared-precision-32"));
check(coarse.brighter()>low.brighter()*1.4 && fine.brighter()<low.brighter()*.8,
"One precision setting controls the visible highlight width: coarse="+coarse+" middle="+low+" fine="+fine);
c.runOnClient(m -> VanillaLight.setShadowPixels(16));frames(c);
for (int distance : new int[]{16,32,64,128,256}) { for (int distance : new int[]{16,32,64,128,256}) {
c.runOnClient(m -> VanillaLight.setEdgeDistance(distance)); c.runOnClient(m -> VanillaLight.setEdgeDistance(distance));
frames(c); frames(c);
@@ -36,6 +36,9 @@ import net.minecraft.world.phys.Vec3;
/** Real terrain/depth-map checks on a new flat seed 122 world; GPU readback is test-only. */ /** Real terrain/depth-map checks on a new flat seed 122 world; GPU readback is test-only. */
public final class PixelShadows122ClientChecks implements FabricClientGameTest { public final class PixelShadows122ClientChecks implements FabricClientGameTest {
private final boolean bloomOnly;
public PixelShadows122ClientChecks(){this(false);}
PixelShadows122ClientChecks(boolean bloomOnly){this.bloomOnly=bloomOnly;}
private static final int FLOOR = 64; private static final int FLOOR = 64;
private static final AABB PILLAR = new AABB(0, FLOOR + 1, 0, 2, FLOOR + 9, 2); 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 SLAB = new BlockPos(5, FLOOR + 3, -5);
@@ -130,6 +133,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
c.runOnClient(m -> check(VanillaLight.active(), "Native Sanctuary shader is active without an external engine")); 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); float morning = c.computeOnClient(m -> m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle);
if(bloomOnly){Bloom130ClientChecks.run(c,server);return;}
// Keep VanillaLight enabled for both frames: changing its fog/grade would invalidate this oracle. // Keep VanillaLight enabled for both frames: changing its fog/grade would invalidate this oracle.
shadows(c, false); shadows(c, false);
var off = capture(c, "day-off"); var off = capture(c, "day-off");
@@ -236,12 +240,13 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
unchanged(beforeReload, capture(c, "after-reload"), "Resource reload preserves the rendered ground shadow"); unchanged(beforeReload, capture(c, "after-reload"), "Resource reload preserves the rendered ground shadow");
RealtimeShadows129ClientChecks.run(c, server); RealtimeShadows129ClientChecks.run(c, server);
EdgeHighlights129ClientChecks.run(c, server); EdgeHighlights129ClientChecks.run(c, server);
Bloom130ClientChecks.run(c, server);
c.getInput().pressKey(InputConstants.KEY_F1); c.getInput().pressKey(InputConstants.KEY_F1);
for (String locale : new String[]{"fr_fr", "en_us"}) { for (String locale : new String[]{"fr_fr", "en_us"}) {
c.runOnClient(m -> { c.runOnClient(m -> {
m.getLanguageManager().setSelected(locale); m.getLanguageManager().setSelected(locale);
m.getLanguageManager().onResourceManagerReload(m.getResourceManager()); m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
for (String key : new String[]{"pixel_shadows", "shadow_pixels", "shadow_distance", "edge_highlights", "edge_intensity", "edge_distance"}) { for (String key : new String[]{"pixel_shadows", "shadow_pixels", "shadow_distance", "edge_highlights", "edge_intensity", "edge_distance", "precision", "bloom", "bloom_radius", "ore_emission"}) {
String fullKey = "sanctuary.shaders." + key; String fullKey = "sanctuary.shaders." + key;
check(!Component.translatable(fullKey).getString().equals(fullKey), "Translated " + locale + " " + key); check(!Component.translatable(fullKey).getString().equals(fullKey), "Translated " + locale + " " + key);
} }
@@ -253,7 +258,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
}); });
}); });
c.waitTicks(3); c.waitTicks(3);
c.takeScreenshot("sanctuary-beta129-shadows-options-" + locale); c.takeScreenshot("sanctuary-beta130-shader-options-" + locale);
} }
c.runOnClient(m -> m.gui.setScreen(null)); c.runOnClient(m -> m.gui.setScreen(null));
} }
@@ -418,6 +423,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
} }
private static void checkPreferences() { private static void checkPreferences() {
VanillaLight.setBloom(false);
VanillaLight.setEnabled(true); VanillaLight.setEnabled(true);
VanillaLight.setEdgeHighlights(false); VanillaLight.setEdgeHighlights(false);
VanillaLight.setSaturation(100); VanillaLight.setSaturation(100);
@@ -517,7 +523,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
} }
static Frame capture(ClientGameTestContext c, String name) { static Frame capture(ClientGameTestContext c, String name) {
if (name != null) c.takeScreenshot("sanctuary-beta129-shadows-" + name); if (name != null) c.takeScreenshot("sanctuary-beta130-shader-" + name);
var result = new CompletableFuture<Frame>(); var result = new CompletableFuture<Frame>();
c.runOnClient(m -> Screenshot.takeScreenshot(m.gameRenderer.mainRenderTarget(), image -> { c.runOnClient(m -> Screenshot.takeScreenshot(m.gameRenderer.mainRenderTarget(), image -> {
try (image) { try (image) {
@@ -0,0 +1,123 @@
package fr.koka.sanctuary.client.shader;
import com.mojang.blaze3d.buffers.Std140Builder;
import com.mojang.blaze3d.pipeline.TextureTarget;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.renderpearl.api.GpuFormat;
import com.mojang.renderpearl.api.buffers.GpuBuffer;
import com.mojang.renderpearl.api.buffers.GpuBufferSlice;
import com.mojang.renderpearl.api.pipeline.*;
import com.mojang.renderpearl.api.textures.FilterMode;
import com.mojang.renderpearl.api.textures.GpuTextureView;
import fr.koka.sanctuary.client.panorama.PanoramaCapture;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.BindGroupLayouts;
import net.minecraft.client.renderer.MappableRingBuffer;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.client.renderer.texture.TextureAtlas;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.dimension.DimensionType;
import net.minecraft.world.level.material.FogType;
import org.joml.Matrix4f;
import org.joml.Vector3f;
import org.joml.Vector4f;
/** Selective emissive bloom. No brightness threshold on ordinary world blocks, no scene-copy or readback. */
public final class Bloom {
private static Identifier id(String path){return Identifier.parse("sanctuary:"+path);}
private static final BindGroupLayout SCENE=BindGroupLayout.builder().withUniform("BloomScene",UniformType.UNIFORM_BUFFER)
.withUniform("WorldDepth",UniformType.COMBINED_IMAGE_SAMPLER).build();
private static final RenderPipeline EMITTERS=RenderPipelines.register(RenderPipeline.builder()
.withLocation(id("pipeline/bloom_emitters")).withVertexShader(id("core/bloom_emitters")).withFragmentShader(id("core/bloom_emitters"))
.withBindGroupLayout(BindGroupLayouts.DYNAMIC_TRANSFORMS).withBindGroupLayout(SCENE)
.withBindGroupLayout(BindGroupLayout.builder().withUniform("BlockAtlas",UniformType.COMBINED_IMAGE_SAMPLER).withUniform("EmissionMasks",UniformType.COMBINED_IMAGE_SAMPLER).build())
.withVertexBinding(0,BloomTerrain.FORMAT).withPrimitiveTopology(PrimitiveTopology.QUADS).withCull(false)
.withColorTargetState(new ColorTargetState(Optional.empty(),GpuFormat.RGBA8_UNORM,ColorTargetState.WRITE_ALL))
.withDepthStencilState(Optional.empty()).build());
private static RenderPipeline screen(String name,BlendFunction blend,boolean scene,boolean blur){
var layout=BindGroupLayout.builder().withUniform("InputTexture",UniformType.COMBINED_IMAGE_SAMPLER);
if(blur)layout.withUniform("BlurParams",UniformType.UNIFORM_BUFFER);
var builder=RenderPipeline.builder().withLocation(id("pipeline/"+name)).withVertexShader("core/screenquad").withFragmentShader(id("core/"+name)).withBindGroupLayout(layout.build());
if(scene)builder.withBindGroupLayout(SCENE);
return RenderPipelines.register(builder.withPrimitiveTopology(PrimitiveTopology.TRIANGLES).withCull(false)
.withColorTargetState(new ColorTargetState(Optional.ofNullable(blend),name.equals("bloom_blur")||name.equals("bloom_downsample")?GpuFormat.RGBA16_FLOAT:GpuFormat.RGBA8_UNORM,blend==null?ColorTargetState.WRITE_ALL:ColorTargetState.WRITE_COLOR)).withDepthStencilState(Optional.empty()).build());
}
private static final RenderPipeline SKY=screen("bloom_sky",null,true,false);
private static final RenderPipeline DOWNSAMPLE=screen("bloom_downsample",null,false,false);
private static final RenderPipeline BLUR=screen("bloom_blur",null,false,true);
private static final RenderPipeline CORE=screen("bloom_core",new BlendFunction(BlendFactor.ONE,BlendFactor.ONE_MINUS_SRC_ALPHA,BlendFactor.ZERO,BlendFactor.ONE),false,false);
private static final RenderPipeline APPLY=screen("bloom_apply",new BlendFunction(BlendFactor.ONE,BlendFactor.ONE_MINUS_SRC_COLOR,BlendFactor.ZERO,BlendFactor.ONE),true,false);
private static final BloomTerrain TERRAIN=new BloomTerrain();
private static final Matrix4f projection=new Matrix4f();
private static TextureTarget emission,small,ping;
private static MappableRingBuffer scene,horizontal,vertical;
private static int frames;
private static boolean applied;
private Bloom(){}
public static void captureProjection(Matrix4f matrix){projection.set(matrix);}
public static void dirtyBlock(BlockPos pos){TERRAIN.dirty(pos);}
public static void dirtyChunk(int x,int z){TERRAIN.dirtyChunk(x,z);}
public static void render(){
applied=false;var mc=Minecraft.getInstance();
if(!VanillaLight.active()||!VanillaLight.bloom()||VanillaLight.bloomIntensity()==0||mc.level==null){release();return;}
if(PanoramaCapture.rendering())return;
var state=mc.gameRenderer.gameRenderState().levelRenderState;var camera=state.cameraRenderState;
if(camera.fogType!=FogType.NONE)return;
var main=mc.gameRenderer.mainRenderTarget();
if(emission==null||emission.width!=main.width||emission.height!=main.height){
targetsClose();emission=target("emission",main.width,main.height);small=target("bloom low",Math.max(1,main.width/8),Math.max(1,main.height/8));ping=target("bloom ping",small.width,small.height);
}
if(scene==null){scene=uniforms("bloom scene",144);horizontal=uniforms("bloom horizontal",16);vertical=uniforms("bloom vertical",16);}
var entries=TERRAIN.update();var atlas=TERRAIN.materials.atlas();
var vp=new Matrix4f(projection).mul(camera.viewRotationMatrix);var inverse=new Matrix4f(vp).invert();
var sun=PixelShadows.sunDirection(state.skyRenderState.sunAngle);var moon=PixelShadows.sunDirection(state.skyRenderState.moonAngle);
float celestial=state.skyRenderState.skybox==DimensionType.Skybox.OVERWORLD?state.skyRenderState.rainBrightness:0;
var fog=camera.fogData;scene.rotate();
try(var mapped=scene.currentBuffer().map(false,true)){
Std140Builder.intoBuffer(mapped.data()).putMat4f(inverse)
.putVec4(VanillaLight.oreEmission()?VanillaLight.oreIntensity()/100f:0,RenderSystem.getDevice().getDeviceInfo().isZZeroToOne()?1:0,VanillaLight.bloomIntensity()/50f,0)
.putVec4(sun.x,sun.y,sun.z,celestial).putVec4(moon.x,moon.y,moon.z,celestial*.35f)
.putVec4(fog.renderDistanceStart,fog.renderDistanceEnd,fog.environmentalStart,fog.environmentalEnd).putVec4(fog.color.w,0,0,0);
}
float radius=.25f+VanillaLight.bloomRadius()*.0075f;
horizontal.rotate();try(var mapped=horizontal.currentBuffer().map(false,true)){Std140Builder.intoBuffer(mapped.data()).putVec4(radius/small.width,0,0,0);}
vertical.rotate();try(var mapped=vertical.currentBuffer().map(false,true)){Std140Builder.intoBuffer(mapped.data()).putVec4(0,radius/small.height,0,0);}
// Gather transforms before opening any pass (the native dynamic ring may resize).
var transforms=new ArrayList<GpuBufferSlice>(entries.size());int max=0;
for(var entry:entries){var pos=entry.origin();var offset=new Vector3f((float)(pos.getX()-camera.pos.x),(float)(pos.getY()-camera.pos.y),(float)(pos.getZ()-camera.pos.z));
transforms.add(RenderSystem.getDynamicUniforms().writeTransform(new Matrix4f(vp).translate(offset),new Vector4f(1,1,1,1),offset,new Matrix4f()));max=Math.max(max,entry.indices());}
var indices=RenderSystem.getSequentialBuffer(PrimitiveTopology.QUADS);indices.requestIndexCount(max);
var nearest=RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST);
screen(SKY,emission.getColorTextureView(),main.getColorTextureView(),true,null);
if(atlas!=null&&!entries.isEmpty())try(var pass=RenderSystem.getDevice().createCommandEncoder().createRenderPass(()->"Sanctuary visible emitters",emission.getColorTextureView(),Optional.empty())){
pass.setPipeline(RenderSystem.getCompiledPipeline(EMITTERS));pass.setUniform("BloomScene",scene.currentBuffer());pass.setUniform("WorldDepth",main.getDepthTextureView(),nearest);
pass.setUniform("BlockAtlas",mc.getTextureManager().getTexture(TextureAtlas.LOCATION_BLOCKS).getTextureView(),nearest);pass.setUniform("EmissionMasks",atlas.getTextureView(),nearest);
pass.setIndexBuffer(indices.getBuffer(),indices.type());
for(int i=0;i<entries.size();i++){var entry=entries.get(i);pass.setUniform("DynamicTransforms",transforms.get(i));pass.setVertexBuffer(0,entry.vertices().slice());pass.drawIndexed(entry.indices(),1,0,0,0);}
}
screen(DOWNSAMPLE,small.getColorTextureView(),emission.getColorTextureView(),false,null);
screen(BLUR,ping.getColorTextureView(),small.getColorTextureView(),false,horizontal);
screen(BLUR,small.getColorTextureView(),ping.getColorTextureView(),false,vertical);
screen(CORE,main.getColorTextureView(),emission.getColorTextureView(),false,null);
screen(APPLY,main.getColorTextureView(),small.getColorTextureView(),true,null);
applied=true;frames++;
}
private static void screen(RenderPipeline pipeline,GpuTextureView output,GpuTextureView input,boolean useScene,MappableRingBuffer blur){
try(var pass=RenderSystem.getDevice().createCommandEncoder().createRenderPass(()->"Sanctuary bloom",output,Optional.empty())){
pass.setPipeline(RenderSystem.getCompiledPipeline(pipeline));pass.setUniform("InputTexture",input,RenderSystem.getSamplerCache().getClampToEdge(pipeline==CORE||pipeline==SKY?FilterMode.NEAREST:FilterMode.LINEAR));
if(useScene){pass.setUniform("BloomScene",scene.currentBuffer());pass.setUniform("WorldDepth",Minecraft.getInstance().gameRenderer.mainRenderTarget().getDepthTextureView(),RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST));}
if(blur!=null)pass.setUniform("BlurParams",blur.currentBuffer());pass.draw(3,1,0,0);
}
}
private static TextureTarget target(String name,int w,int h){return new TextureTarget("Sanctuary "+name,w,h,name.equals("emission")?GpuFormat.RGBA8_UNORM:GpuFormat.RGBA16_FLOAT,null);}
private static MappableRingBuffer uniforms(String name,int size){return new MappableRingBuffer(()->"Sanctuary "+name,GpuBuffer.USAGE_UNIFORM|GpuBuffer.USAGE_MAP_WRITE,size);}
private static void targetsClose(){if(emission!=null){emission.destroyBuffers();small.destroyBuffers();ping.destroyBuffers();emission=small=ping=null;}}
public static void release(){TERRAIN.close();targetsClose();if(scene!=null){scene.close();horizontal.close();vertical.close();scene=horizontal=vertical=null;}applied=false;}
static boolean applied(){return applied;}
static boolean allocated(){return emission!=null;}
static boolean ready(){return TERRAIN.settled();}
static int renderedFrames(){return frames;}
static TextureTarget emissionTarget(){return emission;}
}
@@ -0,0 +1,111 @@
package fr.koka.sanctuary.client.shader;
import com.mojang.blaze3d.platform.NativeImage;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.client.renderer.texture.TextureAtlasSprite;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.block.state.BlockState;
/** Analyse source texels once per resource reload, never rendered frames or GPU pixels. */
final class BloomMaterials implements AutoCloseable {
static final int TILE=68, INNER=64, SIZE=TILE*16, MAX_SPRITES=256;
record Slot(int x,int y,boolean empty) {
float u(float local){return (x+2+Math.clamp(local,0,1)*INNER)/SIZE;}
float v(float local){return (y+2+Math.clamp(local,0,1)*INNER)/SIZE;}
}
private final Map<String,Slot> slots=new HashMap<>();
private final Map<BlockState,Boolean> ores=new IdentityHashMap<>();
private final Map<Identifier,int[]> hosts=new HashMap<>();
private DynamicTexture atlas;
private boolean dirty;
private static final Set<String> ORES=Set.of("iron","copper","redstone","lapis","diamond","emerald","ruby","sapphire","quartz","gold");
boolean ore(BlockState state){return ores.computeIfAbsent(state,s->{
var id=BuiltInRegistries.BLOCK.getKey(s.getBlock());
if(!id.getNamespace().equals("minecraft")&&!id.getNamespace().equals("sanctuary"))return false;
String name=id.getPath().replaceFirst("^(deepslate_|nether_)","");
return name.endsWith("_ore")&&ORES.contains(name.substring(0,name.length()-4));
});}
boolean candidate(BlockState state){return ore(state)||state.getLightEmission()>0||state.emissiveRendering();}
Slot slot(TextureAtlasSprite sprite,boolean ore,boolean fullEmission){
String key=sprite.contents().name()+":"+ore+":"+fullEmission;
var cached=slots.get(key);if(cached!=null)return cached;
if(slots.size()>=MAX_SPRITES)return new Slot(0,0,true);
int index=slots.size(),x=(index%16)*TILE,y=(index/16)*TILE;
if(atlas==null){atlas=new DynamicTexture("Sanctuary emissive masks",SIZE,SIZE,false);atlas.getPixels().fillRect(0,0,SIZE,SIZE,0);}
boolean any=false;
var id=sprite.contents().name();
try(var image=read(id)){
int frameWidth=Math.min(image.getWidth(),sprite.contents().width()),frameHeight=Math.min(image.getHeight(),sprite.contents().height());
int w=Math.min(INNER,frameWidth),h=Math.min(INNER,frameHeight);
int[] colors=new int[w*h];for(int iy=0;iy<h;iy++)for(int ix=0;ix<w;ix++)colors[iy*w+ix]=image.getPixel(ix*frameWidth/w,iy*frameHeight/h);
int[] mask=mask(id,colors,w,h,ore,fullEmission);
// Standard optional _e texture takes precedence, including in personal resource packs.
var override=Minecraft.getInstance().getResourceManager().getResource(texture(id.withSuffix("_e")));
if(override.isPresent())try(var stream=override.get().open();var explicit=NativeImage.read(stream)){
for(int iy=0;iy<h;iy++)for(int ix=0;ix<w;ix++){
int c=explicit.getPixel(ix*explicit.getWidth()/w,iy*Math.min(explicit.getHeight(),frameHeight)/h);
mask[iy*w+ix]=(c>>>24)*Math.max((c>>16)&255,Math.max((c>>8)&255,c&255))/255;
}
}
for(int iy=0;iy<TILE;iy++)for(int ix=0;ix<TILE;ix++){
int value=mask[Math.clamp((iy-2)*h/INNER,0,h-1)*w+Math.clamp((ix-2)*w/INNER,0,w-1)];
any|=value>0;atlas.getPixels().setPixel(x+ix,y+iy,0xFF000000|value*0x010101);
}
}catch(java.io.IOException exception){
fr.koka.sanctuary.SanctuaryMod.LOGGER.warn("Cannot analyse emissive texture {}",id,exception);
}
var result=new Slot(x,y,!any);slots.put(key,result);dirty=true;return result;
}
int[] mask(Identifier id,int[] colors,int width,int height,boolean ore,boolean fullEmission){
int[] result=new int[colors.length];String name=id.getPath();
boolean quartz=name.contains("quartz"),nether=name.contains("nether_gold")||quartz;
int[] host=ore?host(nether?"netherrack":name.contains("deepslate")?"deepslate":"stone"):new int[0];
boolean structural=!ore&&!fullEmission&&(name.contains("furnace_side")||name.contains("furnace_top")
||name.contains("smoker_side")||name.contains("smoker_top")||name.contains("smoker_bottom")
||name.contains("campfire_log")&&!name.contains("lit")||name.contains("respawn_anchor_side")
||name.contains("respawn_anchor_bottom"));
for(int i=0;i<colors.length;i++){
int c=colors[i],r=c>>16&255,g=c>>8&255,b=c&255,max=Math.max(r,Math.max(g,b)),min=Math.min(r,Math.min(g,b));
if((c>>>24)<128||structural)continue;
if(ore){
int difference=255;
for(int base:host){int br=base>>16&255,bg=base>>8&255,bb=base&255;
difference=Math.min(difference,Math.max(Math.abs(r-br),Math.max(Math.abs(g-bg),Math.abs(b-bb))));}
boolean inclusion=nether?(quartz?g>r*.65&&b>r*.5&&max>110:r>110&&g>75&&g>b*1.45):max-min>=10;
if(inclusion&&difference>=10)result[i]=255;
}else{
// Luminosity is tied to the block state; cold/off variants never enter this path.
// Dark casings/wood are suppressed; live atlas colours and animation stay native.
float value=fullEmission?1:Math.clamp((max-100)/110f,0,1);
if(name.contains("torch")||name.contains("furnace")||name.contains("smoker")||name.contains("campfire"))
value*=Math.clamp((max-min-35)/65f,0,1);
result[i]=Math.round(value*255);
}
}
if(ore&&!nether){
// Retain pale/white highlights touching coloured inclusions, without growing into grey rock.
int[] seed=result.clone();
for(int y=0;y<height;y++)for(int x=0;x<width;x++)if(seed[y*width+x]==0){
int c=colors[y*width+x],r=c>>16&255,g=c>>8&255,b=c&255;
if((c>>>24)<128||Math.min(r,Math.min(g,b))<215)continue;
for(int dy=-1;dy<=1;dy++)for(int dx=-1;dx<=1;dx++)if(x+dx>=0&&x+dx<width&&y+dy>=0&&y+dy<height&&seed[(y+dy)*width+x+dx]>0)result[y*width+x]=255;
}
}
return result;
}
private int[] host(String name){return hosts.computeIfAbsent(Identifier.withDefaultNamespace("block/"+name),id->{
try(var image=read(id)){var values=new LinkedHashSet<Integer>();for(int y=0;y<Math.min(64,image.getHeight());y++)for(int x=0;x<Math.min(64,image.getWidth());x++)values.add(image.getPixel(x*image.getWidth()/Math.min(64,image.getWidth()),y*image.getHeight()/Math.min(64,image.getHeight())));return values.stream().mapToInt(Integer::intValue).toArray();}
catch(java.io.IOException exception){return new int[0];}
});}
static Identifier texture(Identifier id){return Identifier.fromNamespaceAndPath(id.getNamespace(),"textures/"+id.getPath()+".png");}
static NativeImage read(Identifier id)throws java.io.IOException{try(var stream=Minecraft.getInstance().getResourceManager().getResourceOrThrow(texture(id)).open()){return NativeImage.read(stream);}}
DynamicTexture atlas(){if(dirty){atlas.upload();dirty=false;}return atlas;}
int count(){return slots.size();}
public void close(){if(atlas!=null){atlas.close();atlas=null;}slots.clear();ores.clear();hosts.clear();dirty=false;}
}
@@ -0,0 +1,147 @@
package fr.koka.sanctuary.client.shader;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.blaze3d.vertex.BufferBuilder;
import com.mojang.blaze3d.vertex.ByteBufferBuilder;
import com.mojang.renderpearl.api.GpuFormat;
import com.mojang.renderpearl.api.buffers.GpuBuffer;
import com.mojang.renderpearl.api.pipeline.PrimitiveTopology;
import com.mojang.renderpearl.api.vertex.VertexFormat;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.model.geom.builders.UVPair;
import net.minecraft.client.renderer.block.dispatch.BlockStateModelPart;
import net.minecraft.client.renderer.texture.TextureAtlas;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.SectionPos;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.chunk.LevelChunkSection;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.phys.Vec3;
/** Native visible sections only, palette rejection and bounded incremental emitter meshing. */
final class BloomTerrain implements AutoCloseable {
static final VertexFormat FORMAT=VertexFormat.builder(0).addAttribute("Position",GpuFormat.RGB32_FLOAT)
.addAttribute("Color",GpuFormat.RGBA8_UNORM).addAttribute("UV0",GpuFormat.RG32_FLOAT)
.addAttribute("UV3",GpuFormat.RG32_FLOAT).build();
private static final long MAX_BYTES=32L*1024*1024;
private final Map<Long,Section> sections=new HashMap<>();
final BloomMaterials materials=new BloomMaterials();
private Build building;
private long bytes;
private Object models,level;
private List<Section> visible=List.of();
record Entry(BlockPos origin,GpuBuffer vertices,int indices){}
List<Entry> update(){
var mc=Minecraft.getInstance();var current=mc.getModelManager().getBlockStateModelSet();
if(models!=current||level!=mc.level){close();models=current;level=mc.level;}
long frame=mc.level.getGameTime();var active=new ArrayList<Section>();
for(var render:mc.levelRenderer.visibleSections()){
long key=render.getSectionNode();var pos=render.getRenderOrigin();
var chunk=mc.level.getChunkSource().getChunk(pos.getX()>>4,pos.getZ()>>4,ChunkStatus.FULL,false);
if(chunk==null)continue;
var storage=chunk.getSection(mc.level.getSectionIndex(pos.getY()));
var section=sections.get(key);
if(section==null||section.storage!=storage){if(section!=null)remove(section);section=new Section(key,pos.immutable(),storage);sections.put(key,section);}
section.seen=frame;active.add(section);
}
visible=active;
for(var iterator=sections.values().iterator();iterator.hasNext();){var section=iterator.next();if(frame-section.seen>40){remove(section);iterator.remove();}}
Vec3 camera=mc.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.pos;
long deadline=System.nanoTime()+3_000_000L;int started=building==null?0:1;
while(System.nanoTime()<deadline){
if(building==null){
if(started++>=4)break;
Section next=active.stream().filter(s->s.dirty).min(Comparator.comparingDouble(s->s.origin.distToCenterSqr(camera))).orElse(null);
if(next==null)break;
if(!next.storage.maybeHas(materials::candidate)){next.dirty=false;continue;}
building=new Build(next);
}
if(!advance(building,deadline))break;
var done=building;building=null;
try(done){
done.section.dirty=false;
if(done.quads>0&&!done.overflow){
try(var mesh=done.out.buildOrThrow()){
long size=mesh.vertexBuffer().remaining();
if(bytes+size<=MAX_BYTES){
var buffer=RenderSystem.getDevice().createBuffer(()->"Sanctuary bloom emitters",GpuBuffer.USAGE_VERTEX|GpuBuffer.USAGE_COPY_DST,mesh.vertexBuffer());
int count=mesh.drawState().indexCount();RenderSystem.getSequentialBuffer(PrimitiveTopology.QUADS).getBuffer(count);
done.section.entry=new Entry(done.section.origin,buffer,count);bytes+=buffer.size();
}
}
}
}
}
return active.stream().map(s->s.entry).filter(Objects::nonNull).toList();
}
private boolean advance(Build build,long deadline){
var mc=Minecraft.getInstance();var models=mc.getModelManager().getBlockStateModelSet();
var parts=new ArrayList<BlockStateModelPart>();var random=RandomSource.create();
while(build.cursor<4096){
int i=build.cursor++,x=i&15,z=i>>4&15,y=i>>8;var state=build.section.storage.getBlockState(x,y,z);
if(materials.candidate(state)){
var pos=build.section.origin.offset(x,y,z);boolean ore=materials.ore(state);
parts.clear();random.setSeed(state.getSeed(pos));if(!state.liquid())models.get(state).collectParts(random,parts);
var tintSources=mc.getBlockColors().getTintSources(state);
var offset=state.getOffset(pos);
for(var part:parts)for(int f=0;f<7;f++){
Direction face=f==6?null:Direction.values()[f];
if(face!=null&&mc.level.getBlockState(pos.relative(face)).isSolidRender())continue;
for(var quad:part.getQuads(face)){
var material=quad.materialInfo();var sprite=material.sprite();
if(!sprite.atlasLocation().equals(TextureAtlas.LOCATION_BLOCKS))continue;
var slot=materials.slot(sprite,ore,material.lightEmission()>0||state.emissiveRendering()||sprite.isAnimated());
if(slot.empty())continue;
if(build.quads>=16384){build.overflow=true;return true;}
int tint=0xFFFFFF,index=material.tintIndex();
if(index>=0&&index<tintSources.size())tint=tintSources.get(index).colorInWorld(state,mc.level,pos)&0xFFFFFF;
// Alpha marks ore vs. native emitter; the mask alpha is not a gameplay light value.
int light=state.emissiveRendering()?15:Math.max(state.getLightEmission(),material.lightEmission());
int alpha=ore?Math.round(light/15f*120):128+Math.round(light/15f*127);
int color=(alpha<<24)|tint;
for(int v=0;v<4;v++){
var p=quad.position(v);float u=UVPair.unpackU(quad.packedUV(v)),tv=UVPair.unpackV(quad.packedUV(v));
float localU=(u-sprite.getU0())/(sprite.getU1()-sprite.getU0()),localV=(tv-sprite.getV0())/(sprite.getV1()-sprite.getV0());
build.out.addVertex(x+p.x()+(float)offset.x,y+p.y()+(float)offset.y,z+p.z()+(float)offset.z)
.setColor(color).setUv(u,tv).setUv3(slot.u(localU),slot.v(localV));
}
build.quads++;
}
}
if(state.liquid()&&state.getLightEmission()>0)fluid(build,pos,x,y,z,state.getFluidState().getHeight(mc.level,pos));
}
if((build.cursor&31)==0&&System.nanoTime()>=deadline)return build.cursor==4096;
}
return true;
}
private void fluid(Build build,BlockPos pos,int x,int y,int z,float height){
var mc=Minecraft.getInstance();var sprite=((TextureAtlas)mc.getTextureManager().getTexture(TextureAtlas.LOCATION_BLOCKS)).getSprite(net.minecraft.resources.Identifier.withDefaultNamespace("block/lava_still"));
var slot=materials.slot(sprite,false,true);if(slot.empty())return;
// Native world depth retains the actual fluid silhouette and occludes these candidate faces.
float[][][] faces={{{0,height,0},{0,height,1},{1,height,1},{1,height,0}},{{0,0,0},{0,height,0},{1,height,0},{1,0,0}},
{{0,0,1},{1,0,1},{1,height,1},{0,height,1}},{{0,0,0},{0,0,1},{0,height,1},{0,height,0}},{{1,0,0},{1,height,0},{1,height,1},{1,0,1}}};
for(var face:faces){if(build.quads>=16384){build.overflow=true;return;}
for(int i=0;i<4;i++){float u=i>=2?1:0,v=i==1||i==2?1:0;var p=face[i];
build.out.addVertex(x+p[0],y+p[1],z+p[2]).setColor(0xFFFFFFFF).setUv(sprite.getU(u),sprite.getV(v)).setUv3(slot.u(u),slot.v(v));}
build.quads++;
}
}
void dirty(BlockPos pos){for(int x=(pos.getX()-1)>>4;x<=(pos.getX()+1)>>4;x++)for(int y=(pos.getY()-1)>>4;y<=(pos.getY()+1)>>4;y++)for(int z=(pos.getZ()-1)>>4;z<=(pos.getZ()+1)>>4;z++)dirty(sections.get(SectionPos.asLong(x,y,z)));}
void dirtyChunk(int x,int z){for(var section:sections.values())if(Math.abs((section.origin.getX()>>4)-x)<=1&&Math.abs((section.origin.getZ()>>4)-z)<=1)dirty(section);}
private void dirty(Section section){if(section!=null){remove(section);section.dirty=true;}}
private void remove(Section section){if(building!=null&&building.section==section){building.close();building=null;}if(section.entry!=null){bytes-=section.entry.vertices.size();section.entry.vertices.close();section.entry=null;}}
boolean settled(){return building==null&&visible.stream().noneMatch(s->s.dirty);}
public void close(){if(building!=null){building.close();building=null;}for(var section:sections.values())remove(section);sections.clear();visible=List.of();materials.close();models=level=null;bytes=0;}
private static final class Section{
final long key;final BlockPos origin;final LevelChunkSection storage;long seen;boolean dirty=true;Entry entry;
Section(long key,BlockPos origin,LevelChunkSection storage){this.key=key;this.origin=origin;this.storage=storage;}
}
private static final class Build implements AutoCloseable{
final Section section;final ByteBufferBuilder buffer=new ByteBufferBuilder(65536);final BufferBuilder out=new BufferBuilder(buffer,PrimitiveTopology.QUADS,FORMAT);
int cursor,quads;boolean overflow;
Build(Section section){this.section=section;}
public void close(){buffer.close();}
}
}
@@ -68,7 +68,7 @@ public final class EdgeHighlights {
Std140Builder.intoBuffer(mapped.data()).putMat4f(inverse).putMat4f(viewProjection) Std140Builder.intoBuffer(mapped.data()).putMat4f(inverse).putMat4f(viewProjection)
.putVec4((float)(camera.pos.x - Math.floor(camera.pos.x)), .putVec4((float)(camera.pos.x - Math.floor(camera.pos.x)),
(float)(camera.pos.y - Math.floor(camera.pos.y)), (float)(camera.pos.y - Math.floor(camera.pos.y)),
(float)(camera.pos.z - Math.floor(camera.pos.z)), 16) (float)(camera.pos.z - Math.floor(camera.pos.z)), VanillaLight.shadowPixels())
.putVec4(VanillaLight.edgeIntensity() * .004f, .putVec4(VanillaLight.edgeIntensity() * .004f,
RenderSystem.getDevice().getDeviceInfo().isZZeroToOne() ? 1 : 0, RenderSystem.getDevice().getDeviceInfo().isZZeroToOne() ? 1 : 0,
fog.renderDistanceStart, fog.renderDistanceEnd) fog.renderDistanceStart, fog.renderDistanceEnd)
@@ -17,6 +17,16 @@ public final class ShaderOptionsScreen extends OptionsSubScreen {
list.addBig(new OptionInstance<Integer>("sanctuary.shaders.saturation",OptionInstance.cachedConstantTooltip( 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), Component.translatable("sanctuary.shaders.saturation_help")),(caption,value)->Component.translatable("sanctuary.shaders.saturation_value",value),
new OptionInstance.IntRange(0,200),VanillaLight.saturation(),VanillaLight::setSaturation)); new OptionInstance.IntRange(0,200),VanillaLight.saturation(),VanillaLight::setSaturation));
list.addBig(new OptionInstance<Integer>("sanctuary.shaders.precision",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.precision_help")),(caption,value)->Component.translatable("sanctuary.shaders.precision_value",value),
new OptionInstance.Enum<>(List.of(8,16,32),Codec.INT),VanillaLight.shadowPixels(),VanillaLight::setShadowPixels));
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.bloom",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.bloom_help")),VanillaLight.bloom(),VanillaLight::setBloom));
slider("bloom_intensity",VanillaLight.bloomIntensity(),VanillaLight::setBloomIntensity);
slider("bloom_radius",VanillaLight.bloomRadius(),VanillaLight::setBloomRadius);
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.ore_emission",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.ore_emission_help")),VanillaLight.oreEmission(),VanillaLight::setOreEmission));
slider("ore_intensity",VanillaLight.oreIntensity(),VanillaLight::setOreIntensity);
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.edge_highlights",OptionInstance.cachedConstantTooltip( list.addBig(OptionInstance.createBoolean("sanctuary.shaders.edge_highlights",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.edge_highlights_help")), Component.translatable("sanctuary.shaders.edge_highlights_help")),
VanillaLight.edgeHighlights(),VanillaLight::setEdgeHighlights)); VanillaLight.edgeHighlights(),VanillaLight::setEdgeHighlights));
@@ -29,11 +39,13 @@ public final class ShaderOptionsScreen extends OptionsSubScreen {
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.pixel_shadows",OptionInstance.cachedConstantTooltip( list.addBig(OptionInstance.createBoolean("sanctuary.shaders.pixel_shadows",OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders.pixel_shadows_help")), Component.translatable("sanctuary.shaders.pixel_shadows_help")),
VanillaLight.pixelShadows(),VanillaLight::setPixelShadows)); 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( 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), 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,128,256),Codec.INT),VanillaLight.shadowDistance(),VanillaLight::setShadowDistance)); new OptionInstance.Enum<>(List.of(16,32,64,128,256),Codec.INT),VanillaLight.shadowDistance(),VanillaLight::setShadowDistance));
} }
private void slider(String key,int value,java.util.function.Consumer<Integer> setter){
list.addBig(new OptionInstance<Integer>("sanctuary.shaders."+key,OptionInstance.cachedConstantTooltip(
Component.translatable("sanctuary.shaders."+key+"_help")),(caption,v)->Component.translatable("sanctuary.shaders."+key+"_value",v),
new OptionInstance.IntRange(0,100),value,setter::accept));
}
} }
@@ -17,12 +17,17 @@ public final class VanillaLight {
private static final class Preferences { private static final class Preferences {
boolean enabled=true; boolean enabled=true;
int saturation=142; int saturation=142;
boolean pixelShadows=false; boolean pixelShadows=true;
boolean bloom=false;
int bloomIntensity=35;
int bloomRadius=50;
boolean oreEmission=true;
int oreIntensity=50;
boolean edgeHighlights=false; boolean edgeHighlights=false;
int edgeIntensity=30; int edgeIntensity=30;
int edgeDistance=32; int edgeDistance=32;
int shadowPixels=16; int shadowPixels=16;
int shadowDistance=32; int shadowDistance=128;
} }
private static Path path(){return FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");} private static Path path(){return FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");}
public static boolean enabled(){ public static boolean enabled(){
@@ -30,6 +35,9 @@ public final class VanillaLight {
try{if(Files.isRegularFile(path()))preferences=new GsonBuilder().create().fromJson(Files.readString(path()),Preferences.class);} try{if(Files.isRegularFile(path()))preferences=new GsonBuilder().create().fromJson(Files.readString(path()),Preferences.class);}
catch(Exception e){SanctuaryMod.LOGGER.warn("Cannot read Sanctuary shader preference",e);} catch(Exception e){SanctuaryMod.LOGGER.warn("Cannot read Sanctuary shader preference",e);}
if(preferences==null)preferences=new Preferences(); if(preferences==null)preferences=new Preferences();
preferences.bloomIntensity=Math.clamp(preferences.bloomIntensity,0,100);
preferences.bloomRadius=Math.clamp(preferences.bloomRadius,0,100);
preferences.oreIntensity=Math.clamp(preferences.oreIntensity,0,100);
preferences.edgeIntensity=Math.clamp(preferences.edgeIntensity,0,100); preferences.edgeIntensity=Math.clamp(preferences.edgeIntensity,0,100);
preferences.edgeDistance=normalizeShadowDistance(preferences.edgeDistance); preferences.edgeDistance=normalizeShadowDistance(preferences.edgeDistance);
preferences.saturation=Math.clamp(preferences.saturation,0,200); preferences.saturation=Math.clamp(preferences.saturation,0,200);
@@ -47,6 +55,16 @@ public final class VanillaLight {
public static int edgeIntensity(){enabled();return preferences.edgeIntensity;} public static int edgeIntensity(){enabled();return preferences.edgeIntensity;}
public static void setEdgeHighlights(boolean value){enabled();preferences.edgeHighlights=value;save();} public static void setEdgeHighlights(boolean value){enabled();preferences.edgeHighlights=value;save();}
public static void setEdgeIntensity(int value){enabled();preferences.edgeIntensity=Math.clamp(value,0,100);save();} public static void setEdgeIntensity(int value){enabled();preferences.edgeIntensity=Math.clamp(value,0,100);save();}
public static boolean bloom(){enabled();return preferences.bloom;}
public static int bloomIntensity(){enabled();return preferences.bloomIntensity;}
public static int bloomRadius(){enabled();return preferences.bloomRadius;}
public static boolean oreEmission(){enabled();return preferences.oreEmission;}
public static int oreIntensity(){enabled();return preferences.oreIntensity;}
public static void setBloom(boolean value){enabled();preferences.bloom=value;save();}
public static void setBloomIntensity(int value){enabled();preferences.bloomIntensity=Math.clamp(value,0,100);save();}
public static void setBloomRadius(int value){enabled();preferences.bloomRadius=Math.clamp(value,0,100);save();}
public static void setOreEmission(boolean value){enabled();preferences.oreEmission=value;save();}
public static void setOreIntensity(int value){enabled();preferences.oreIntensity=Math.clamp(value,0,100);save();}
public static boolean pixelShadows(){enabled();return preferences.pixelShadows;} public static boolean pixelShadows(){enabled();return preferences.pixelShadows;}
public static int shadowPixels(){enabled();return preferences.shadowPixels;} public static int shadowPixels(){enabled();return preferences.shadowPixels;}
public static int shadowDistance(){enabled();return preferences.shadowDistance;} public static int shadowDistance(){enabled();return preferences.shadowDistance;}
@@ -1,6 +1,7 @@
package fr.koka.sanctuary.mixin.client; package fr.koka.sanctuary.mixin.client;
import fr.koka.sanctuary.client.shader.PixelShadows; import fr.koka.sanctuary.client.shader.PixelShadows;
import fr.koka.sanctuary.client.shader.Bloom;
import net.minecraft.client.renderer.extract.LevelExtractor; import net.minecraft.client.renderer.extract.LevelExtractor;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Mixin;
@@ -13,8 +14,8 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
abstract class PixelShadowDirtyMixin { abstract class PixelShadowDirtyMixin {
@Inject(method="blockChanged", at=@At("HEAD")) @Inject(method="blockChanged", at=@At("HEAD"))
private void sanctuary$dirty(BlockPos pos, int flags, CallbackInfo ci) { private void sanctuary$dirty(BlockPos pos, int flags, CallbackInfo ci) {
PixelShadows.dirtyBlock(pos); PixelShadows.dirtyBlock(pos); Bloom.dirtyBlock(pos);
} }
@Inject(method="allChanged", at=@At("HEAD")) @Inject(method="allChanged", at=@At("HEAD"))
private void sanctuary$reload(CallbackInfo ci) { PixelShadows.release(); } private void sanctuary$reload(CallbackInfo ci) { PixelShadows.release(); Bloom.release(); }
} }
@@ -1,6 +1,7 @@
package fr.koka.sanctuary.mixin.client; package fr.koka.sanctuary.mixin.client;
import fr.koka.sanctuary.client.shader.PixelShadows; import fr.koka.sanctuary.client.shader.PixelShadows;
import fr.koka.sanctuary.client.shader.Bloom;
import net.minecraft.client.multiplayer.ClientLevel; import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.block.state.BlockState;
@@ -15,11 +16,11 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
abstract class PixelShadowLevelMixin { abstract class PixelShadowLevelMixin {
@Inject(method="setBlocksDirty", at=@At("HEAD")) @Inject(method="setBlocksDirty", at=@At("HEAD"))
private void sanctuary$block(BlockPos pos, BlockState before, BlockState after, CallbackInfo ci) { private void sanctuary$block(BlockPos pos, BlockState before, BlockState after, CallbackInfo ci) {
if (before != after) PixelShadows.dirtyBlock(pos); if (before != after) {PixelShadows.dirtyBlock(pos);Bloom.dirtyBlock(pos);}
} }
@Inject(method="onChunkLoaded", at=@At("TAIL")) @Inject(method="onChunkLoaded", at=@At("TAIL"))
private void sanctuary$loaded(ChunkPos pos, CallbackInfo ci) { PixelShadows.dirtyChunk(pos.x(), pos.z()); } private void sanctuary$loaded(ChunkPos pos, CallbackInfo ci) { PixelShadows.dirtyChunk(pos.x(), pos.z()); Bloom.dirtyChunk(pos.x(), pos.z()); }
@Inject(method="unload", at=@At("HEAD")) @Inject(method="unload", at=@At("HEAD"))
private void sanctuary$unload(LevelChunk chunk, CallbackInfo ci) { PixelShadows.dirtyChunk(chunk.getPos().x(), chunk.getPos().z()); } private void sanctuary$unload(LevelChunk chunk, CallbackInfo ci) { PixelShadows.dirtyChunk(chunk.getPos().x(), chunk.getPos().z()); Bloom.dirtyChunk(chunk.getPos().x(), chunk.getPos().z()); }
} }
@@ -5,6 +5,7 @@ import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
import com.mojang.renderpearl.api.buffers.GpuBufferSlice; import com.mojang.renderpearl.api.buffers.GpuBufferSlice;
import fr.koka.sanctuary.client.shader.PixelShadows; import fr.koka.sanctuary.client.shader.PixelShadows;
import fr.koka.sanctuary.client.shader.EdgeHighlights; import fr.koka.sanctuary.client.shader.EdgeHighlights;
import fr.koka.sanctuary.client.shader.Bloom;
import net.minecraft.client.renderer.GameRenderer; import net.minecraft.client.renderer.GameRenderer;
import net.minecraft.client.renderer.ProjectionMatrixBuffer; import net.minecraft.client.renderer.ProjectionMatrixBuffer;
import org.joml.Matrix4f; import org.joml.Matrix4f;
@@ -19,10 +20,11 @@ abstract class PixelShadowRendererMixin {
private GpuBufferSlice sanctuary$projection(ProjectionMatrixBuffer buffer, Matrix4f matrix, Operation<GpuBufferSlice> original) { private GpuBufferSlice sanctuary$projection(ProjectionMatrixBuffer buffer, Matrix4f matrix, Operation<GpuBufferSlice> original) {
PixelShadows.captureProjection(matrix); PixelShadows.captureProjection(matrix);
EdgeHighlights.captureProjection(matrix); EdgeHighlights.captureProjection(matrix);
Bloom.captureProjection(matrix);
return original.call(buffer, 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")) @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(); EdgeHighlights.render(); } private void sanctuary$shadows(CallbackInfo ci) { PixelShadows.render(); EdgeHighlights.render(); Bloom.render(); }
@Inject(method={"close", "setLevel"}, at=@At("HEAD")) @Inject(method={"close", "setLevel"}, at=@At("HEAD"))
private void sanctuary$close(CallbackInfo ci) { PixelShadows.release(); EdgeHighlights.release(); } private void sanctuary$close(CallbackInfo ci) { PixelShadows.release(); EdgeHighlights.release(); Bloom.release(); }
} }
@@ -2263,7 +2263,7 @@
"sanctuary.shaders.edge_intensity_help": "Highlight strength along edges. Independent of cast shadows.", "sanctuary.shaders.edge_intensity_help": "Highlight strength along edges. Independent of cast shadows.",
"sanctuary.shaders.edge_intensity_value": "Edge intensity: %s %%", "sanctuary.shaders.edge_intensity_value": "Edge intensity: %s %%",
"sanctuary.shaders.pixel_shadows": "Pixel shadows", "sanctuary.shaders.pixel_shadows": "Pixel shadows",
"sanctuary.shaders.pixel_shadows_help": "Sun shadows and subtle moon shadows on a pixel grid. Dappled foliage and stable contours. Disabled by default; requires Vanilla Light.", "sanctuary.shaders.pixel_shadows_help": "Sun shadows and subtle moon shadows on a pixel grid. Dappled foliage and stable contours. Enabled by default at 128 blocks; requires Vanilla Light.",
"sanctuary.shaders.shadow_pixels": "Shadow precision", "sanctuary.shaders.shadow_pixels": "Shadow precision",
"sanctuary.shaders.shadow_pixels_value": "%s pixels/block", "sanctuary.shaders.shadow_pixels_value": "%s pixels/block",
"sanctuary.shaders.shadow_pixels_help": "Grid precision: 8, 16 or 32 pixels per block. Higher values make shadow pixels finer. Requires Vanilla Light and pixel shadows.", "sanctuary.shaders.shadow_pixels_help": "Grid precision: 8, 16 or 32 pixels per block. Higher values make shadow pixels finer. Requires Vanilla Light and pixel shadows.",
@@ -2272,5 +2272,21 @@
"sanctuary.shaders.shadow_distance_help": "Shadow range: 16, 32, 64, 128 or 256 blocks, within loaded terrain. Higher distances cost more performance. Requires Vanilla Light and pixel shadows.", "sanctuary.shaders.shadow_distance_help": "Shadow range: 16, 32, 64, 128 or 256 blocks, within loaded terrain. Higher distances cost more performance. Requires Vanilla Light and pixel shadows.",
"sanctuary.shaders.edge_distance": "Highlight distance", "sanctuary.shaders.edge_distance": "Highlight distance",
"sanctuary.shaders.edge_distance_value": "%s blocks", "sanctuary.shaders.edge_distance_value": "%s blocks",
"sanctuary.shaders.edge_distance_help": "Independent range: 16, 32, 64, 128 or 256 blocks. Limited to visible terrain and render distance. Distant contours become naturally finer." "sanctuary.shaders.edge_distance_help": "Independent range: 16, 32, 64, 128 or 256 blocks. Limited to visible terrain and render distance. Distant contours become naturally finer.",
"sanctuary.shaders.precision": "Precision",
"sanctuary.shaders.precision_help": "Shared grid for shadows and edge highlights: 8, 16 or 32 pixels per block.",
"sanctuary.shaders.precision_value": "%s pixels/block",
"sanctuary.shaders.bloom": "Bloom",
"sanctuary.shaders.bloom_help": "Glow around the sun and luminous surfaces. Ores can also become emissive. Requires Vanilla Light. OFF by default.",
"sanctuary.shaders.bloom_intensity": "Bloom strength",
"sanctuary.shaders.bloom_intensity_help": "Halo strength. Zero disables bloom and frees its resources.",
"sanctuary.shaders.bloom_intensity_value": "Bloom: %s%%",
"sanctuary.shaders.bloom_radius": "Bloom spread",
"sanctuary.shaders.bloom_radius_help": "Halo size around luminous pixels. Texture details stay sharp.",
"sanctuary.shaders.bloom_radius_value": "Spread: %s%%",
"sanctuary.shaders.ore_emission": "Emissive ores",
"sanctuary.shaders.ore_emission_help": "With bloom: makes ore inclusions glow while preserving their host rock. Visual effect only.",
"sanctuary.shaders.ore_intensity": "Ore emission",
"sanctuary.shaders.ore_intensity_help": "Inclusion brightness. Does not change world light levels or mob spawning.",
"sanctuary.shaders.ore_intensity_value": "Ores: %s%%"
} }
@@ -2263,7 +2263,7 @@
"sanctuary.shaders.edge_intensity_help": "Force du reflet sur les arêtes. Le réglage est indépendant des ombres portées.", "sanctuary.shaders.edge_intensity_help": "Force du reflet sur les arêtes. Le réglage est indépendant des ombres portées.",
"sanctuary.shaders.edge_intensity_value": "Intensité des arêtes : %s %%", "sanctuary.shaders.edge_intensity_value": "Intensité des arêtes : %s %%",
"sanctuary.shaders.pixel_shadows": "Ombres pixelisées", "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 contours stables. Désactivées par défaut ; nécessite Vanilla Light.", "sanctuary.shaders.pixel_shadows_help": "Ombres du soleil et ombres légères de la lune, sur une grille de pixels. Feuillage irrégulier et contours stables. Activées par défaut, portée 128 blocs ; nécessite Vanilla Light.",
"sanctuary.shaders.shadow_pixels": "Précision des ombres", "sanctuary.shaders.shadow_pixels": "Précision des ombres",
"sanctuary.shaders.shadow_pixels_value": "%s pixels/bloc", "sanctuary.shaders.shadow_pixels_value": "%s pixels/bloc",
"sanctuary.shaders.shadow_pixels_help": "Précision de la grille : 8, 16 ou 32 pixels par bloc. Plus la valeur est élevée, plus les pixels des ombres sont fins. Nécessite Vanilla Light et les ombres pixelisées.", "sanctuary.shaders.shadow_pixels_help": "Précision de la grille : 8, 16 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.",
@@ -2272,5 +2272,21 @@
"sanctuary.shaders.shadow_distance_help": "Portée des ombres : 16, 32, 64, 128 ou 256 blocs, dans le terrain chargé. Une distance élevée coûte davantage en performances. Nécessite Vanilla Light et les ombres pixelisées.", "sanctuary.shaders.shadow_distance_help": "Portée des ombres : 16, 32, 64, 128 ou 256 blocs, dans le terrain chargé. Une distance élevée coûte davantage en performances. Nécessite Vanilla Light et les ombres pixelisées.",
"sanctuary.shaders.edge_distance": "Portée des arêtes", "sanctuary.shaders.edge_distance": "Portée des arêtes",
"sanctuary.shaders.edge_distance_value": "%s blocs", "sanctuary.shaders.edge_distance_value": "%s blocs",
"sanctuary.shaders.edge_distance_help": "Portée indépendante : 16, 32, 64, 128 ou 256 blocs. Limitée au terrain visible et à la distance de rendu. Les contours lointains saffinent naturellement." "sanctuary.shaders.edge_distance_help": "Portée indépendante : 16, 32, 64, 128 ou 256 blocs. Limitée au terrain visible et à la distance de rendu. Les contours lointains saffinent naturellement.",
"sanctuary.shaders.precision": "Précision",
"sanctuary.shaders.precision_help": "Grille commune aux ombres et aux reflets sur les arêtes : 8, 16 ou 32 pixels par bloc.",
"sanctuary.shaders.precision_value": "%s pixels/bloc",
"sanctuary.shaders.bloom": "Bloom",
"sanctuary.shaders.bloom_help": "Halo du soleil et des surfaces lumineuses. Les minerais peuvent aussi devenir émissifs. Nécessite Vanilla Light. Désactivé par défaut.",
"sanctuary.shaders.bloom_intensity": "Intensité du bloom",
"sanctuary.shaders.bloom_intensity_help": "Force du halo. Zéro désactive le bloom et libère ses ressources.",
"sanctuary.shaders.bloom_intensity_value": "Bloom : %s %%",
"sanctuary.shaders.bloom_radius": "Diffusion du bloom",
"sanctuary.shaders.bloom_radius_help": "Taille du halo autour des pixels lumineux. Les détails des textures restent nets.",
"sanctuary.shaders.bloom_radius_value": "Diffusion : %s %%",
"sanctuary.shaders.ore_emission": "Minerais émissifs",
"sanctuary.shaders.ore_emission_help": "Avec le bloom : fait briller les inclusions des minerais sans éclairer leur roche. Effet visuel uniquement.",
"sanctuary.shaders.ore_intensity": "Émission des minerais",
"sanctuary.shaders.ore_intensity_help": "Luminosité des inclusions. Aucun changement de la lumière du monde ou des apparitions de mobs.",
"sanctuary.shaders.ore_intensity_value": "Minerais : %s %%"
} }
@@ -0,0 +1,7 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
#include <sanctuary:bloom_scene.glsl>
uniform sampler2D InputTexture;
layout(location=0) in vec2 texCoord;
layout(location=0) out vec4 fragColor;
void main(){vec3 halo=texture(InputTexture,texCoord).rgb;fragColor=vec4(clamp(halo*BloomParams.z,0.0,1.0),0.0);}
@@ -0,0 +1,13 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
uniform sampler2D InputTexture;
layout(std140) uniform BlurParams { vec4 Step; };
layout(location=0) in vec2 texCoord;
layout(location=0) out vec4 fragColor;
// At most one low-resolution texel between taps: broad halos cannot turn into a sparse grid.
void main(){
float weights[13]=float[13](0.099908358,0.096834501,0.088168817,0.075414785,0.060597482,0.045741379,0.032435495,0.021606698,0.013521126,0.007948660,0.004389667,0.002277329,0.001109882);
vec3 color=texture(InputTexture,texCoord).rgb*weights[0];
for(int i=1;i<=12;i++){vec2 d=Step.xy*float(i);color+=(texture(InputTexture,texCoord+d).rgb+texture(InputTexture,texCoord-d).rgb)*weights[i];}
fragColor=vec4(color,0.0);
}
@@ -0,0 +1,6 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
uniform sampler2D InputTexture;
layout(location=0) in vec2 texCoord;
layout(location=0) out vec4 fragColor;
void main(){vec4 emission=texture(InputTexture,texCoord);fragColor=emission.a>0.0?emission:vec4(0.0);}
@@ -0,0 +1,6 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
uniform sampler2D InputTexture;
layout(location=0) in vec2 texCoord;
layout(location=0) out vec4 fragColor;
void main(){vec2 pixel=1.0/vec2(textureSize(InputTexture,0));vec3 sum=vec3(0.0);for(int y=0;y<8;y++)for(int x=0;x<8;x++)sum+=texture(InputTexture,texCoord+(vec2(x,y)-3.5)*pixel).rgb;fragColor=vec4(sum/64.0,0.0);}
@@ -0,0 +1,22 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
#include <sanctuary:bloom_scene.glsl>
uniform sampler2D BlockAtlas;
uniform sampler2D EmissionMasks;
layout(location=0) in vec3 relativePosition;
layout(location=1) in vec2 blockUv;
layout(location=2) in vec2 maskUv;
layout(location=3) in vec4 tint;
layout(location=0) out vec4 fragColor;
void main(){
vec2 uv=gl_FragCoord.xy/vec2(textureSize(WorldDepth,0));
float depth=texture(WorldDepth,uv).r;
if(depth>0.0&&length(relativePosition)>length(bloomPosition(uv,depth))+0.02)discard;
vec4 source=texture(BlockAtlas,blockUv);
float mask=texture(EmissionMasks,maskUv).r;
bool ore=tint.a<0.5;
float nativeLight=ore?tint.a*(255.0/120.0):max(0.0,(tint.a*255.0-128.0)/127.0);
float amount=mask*(ore?max(BloomParams.x,nativeLight):nativeLight)*bloomFog(relativePosition);
if(source.a<0.5||amount<0.001)discard;
fragColor=vec4(source.rgb*tint.rgb*amount,ore?amount:0.0);
}
@@ -0,0 +1,12 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
#include <minecraft:dynamictransforms.glsl>
layout(location=0) in vec3 Position;
layout(location=1) in vec4 Color;
layout(location=2) in vec2 UV0;
layout(location=3) in vec2 UV3;
layout(location=0) out vec3 relativePosition;
layout(location=1) out vec2 blockUv;
layout(location=2) out vec2 maskUv;
layout(location=3) out vec4 tint;
void main(){gl_Position=ModelViewMat*vec4(Position,1.0);relativePosition=Position+ModelOffset;blockUv=UV0;maskUv=UV3;tint=Color;}
@@ -0,0 +1,15 @@
#version 330
#extension GL_ARB_separate_shader_objects : require
#include <sanctuary:bloom_scene.glsl>
uniform sampler2D InputTexture;
layout(location=0) in vec2 texCoord;
layout(location=0) out vec4 fragColor;
void main(){
fragColor=vec4(0.0);
if(texture(WorldDepth,texCoord).r>0.0)return;
vec3 ray=normalize(bloomPosition(texCoord,0.00001));
float celestial=max(step(0.955,dot(ray,SunDirection.xyz))*SunDirection.w,step(0.975,dot(ray,MoonDirection.xyz))*MoonDirection.w);
vec3 color=texture(InputTexture,texCoord).rgb;
float luminance=dot(color,vec3(.2126,.7152,.0722));
fragColor=vec4(color*smoothstep(.72,.98,luminance)*celestial,0.0);
}
@@ -0,0 +1,18 @@
uniform sampler2D WorldDepth;
layout(std140) uniform BloomScene {
mat4 InverseViewProjection;
vec4 BloomParams;
vec4 SunDirection;
vec4 MoonDirection;
vec4 FogDistance;
vec4 FogInfo;
};
vec3 bloomPosition(vec2 uv,float depth) {
vec4 p=InverseViewProjection*vec4(uv*2.0-1.0,BloomParams.y>0.5?depth:depth*2.0-1.0,1.0);
return p.xyz/p.w;
}
float bloomFog(vec3 position) {
float a=clamp((max(length(position.xz),abs(position.y))-FogDistance.x)/max(.001,FogDistance.y-FogDistance.x),0.0,1.0);
float b=clamp((length(position)-FogDistance.z)/max(.001,FogDistance.w-FogDistance.z),0.0,1.0);
return 1.0-max(a,b)*FogInfo.x;
}
+1 -1
View File
@@ -1,6 +1,6 @@
name = "Sanctuary" name = "Sanctuary"
author = "KOKA99CAB" author = "KOKA99CAB"
version = "beta.129" version = "beta.130"
pack-format = "packwiz:1.1.0" pack-format = "packwiz:1.1.0"
[index] [index]