diff --git a/README.md b/README.md index dbe0435..57e37ec 100644 --- a/README.md +++ b/README.md @@ -20,14 +20,13 @@ La [vision complète](docs/vision.md) conserve les intentions ; le L’économie, les claims et les dimensions décrits dans la vision restent à implémenter. -La version **beta.128** stabilise les ombres pixélisées, atténue le soleil au -zénith et ajoute les ombres légères de la lune et le feuillage irrégulier. -**Options → Shaders → Sanctuary · Vanilla Light** : les réglages de finesse et -de portée sont conservés. Le [ticket de livraison](docs/shader-beta128.md) -décrit le résultat et ses vérifications. Les gemmes, sculptures et découvertes -de beta.126 restent inchangées. La release est publiée et l'instance -**Sanctuary Beta** synchronisée, sauvegardes et réglages conservés. Packs -normal/Test et template sont également disponibles dans `build/`. +La version **beta.129** ajoute les reflets CTM optionnels sur les contours du +relief, avec une portée indépendante de 16 à 256 blocs. Les surfaces plates +se raccordent entre tous les matériaux. Les ombres gagnent les portées +128/256, gardent leur intensité à midi et ne réalignent plus leur grille à +chaque mouvement du soleil. **Options → Shaders → Sanctuary · Vanilla Light**. +Le [ticket de livraison](docs/shader-beta129.md) précise le résultat et ses +vérifications. Publication et synchronisation en cours de préparation. 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. @@ -38,6 +37,12 @@ La [carte native beta.005](docs/atlas-beta005.md) prend le relais après l’étude de Xaero et des alternatives libres. +## beta.129 — Reflets CTM et ombres jusqu'à 256 blocs + +Reflets connectés réglables, cinq portées indépendantes, soleil plein à midi +et correction du réalignement cyclique de la grille d'ombres. +[Contrat et vérifications](docs/shader-beta129.md). + ## beta.128 — Ombres stables, lune et feuillage Grille de réception stabilisée, contours nets des blocs, atténuation à midi, diff --git a/docs/shader-beta129.md b/docs/shader-beta129.md new file mode 100644 index 0000000..c20e746 --- /dev/null +++ b/docs/shader-beta129.md @@ -0,0 +1,109 @@ +# SHADER-129 — arêtes connectées et portées étendues + +Demandes du 17 septembre 2026 : highlight optionnel, puis raccordement CTM de +tous les blocs coplanaires, portée indépendante 16–256 blocs, ombres à 128/256, +suppression de l'atténuation au zénith et du réalignement cyclique des ombres. +Branche `codex/edge-highlights-beta129`, socle beta.128, Minecraft 26.3 / Java 25. + +## Résultat + +Dans **Options → Shaders → Sanctuary · Vanilla Light**, les reflets sur les +arêtes sont désactivés par défaut. L'intensité va de 0 à 100 ; la valeur 30 +éclaircit au maximum de 12 %. Leur portée se règle indépendamment des ombres : +**16, 32, 64, 128 ou 256 blocs**. Les anciens réglages restent conservés. + +Le highlight raccorde les surfaces plates, y compris entre matériaux différents, +sans dessiner les jonctions intérieures. Il suit les contours visibles du relief, +y compris les dalles et escaliers. Il multiplie la couleur existante pour garder +les pixels des textures et composer avec les ombres. Ce CTM géométrique utilise +la profondeur déjà produite par Minecraft : aucune liste de blocs autorisés, +aucun pack CTM à fournir, aucune exploration CPU du volume de 256 blocs. +Une cassure/repose modifie le contour dès que le terrain natif est réaffiché. + +Le trait proche suit la grille de 1/16 de bloc. À distance, la couverture devient +partielle pour limiter le scintillement des traits plus petits qu'un pixel. +La portée choisie s'efface progressivement dans son dernier quart et respecte +la brume ainsi que la distance de rendu. Une passe plein écran, quatre sondes +de profondeur et un tampon uniforme de 176 octets ; aucun retour GPU vers CPU +en production. L'arrêt de l'option ou une intensité nulle libèrent ce tampon. + +Ce n'est pas un remplacement des textures par des variantes CTM. Le calcul +concerne les faces visibles alignées sur la grille : les faces hors écran, +les surfaces transparentes absentes de la profondeur et les géométries +arbitrairement inclinées ne sont pas reconstruites. À très grande distance, +les détails plus petits qu'un pixel peuvent rester imperceptibles. Aucun +chargement de chunk forcé, aucune promesse de FPS. + +Les ombres proposent les cinq mêmes portées. La carte reste plafonnée à +4096 × 4096 : les grandes distances réduisent sa précision spatiale. Le cache +garde ses limites de 96 Mio, quatre sections et 3 ms souples de construction +par image ; la réconciliation des chunks est limitée aux ticks et changements +de caméra/terrain plutôt qu'à toutes les images. + +L'intensité solaire reste pleine au zénith. Les contours opaques restent nets, +le filtrage du feuillage et les ombres légères de la lune sont conservés. +Le réalignement de la grille lumineuse a lieu au changement d'ancrage de la +caméra, avec transfert de phase par texels entiers. La rotation du soleil ne +réarrondit plus la projection de l'origine mondiale à chaque image, source +de sauts périodiques amplifiés aux grandes coordonnées. Le soleil continue +d'avancer ; une ombre pixélisée peut naturellement changer de case. Cette +correction ne modifie pas l'horloge serveur ni sa cadence native. + +Textes FR/EN. Aucun changement de règles, sauvegardes, génération, gemmes, +sculptures ou découvertes. + +## Vérifications et livraison + +Le client natif réussit en **2 min 41 s**, dans un monde neuf plat, graine 122, +sur macOS / Apple M1 / OpenGL : + +- CTM : 596 échantillons éclaircis sur le contour et 65 068 conservés ; les + 20 centres et 20 jonctions coplanaires ciblés restent intacts, y compris + au changement de matériau. L'intensité 90 produit environ trois fois le gain. +- Ouverture d'un trou : 716 échantillons éclaircis ; la fermeture restitue + l'image connectée. Au-delà de 16 blocs, le réglage 16 supprime le contour ; + le réglage 64 le rétablit (254 échantillons éclaircis). +- Cinq portées CTM et cinq portées d'ombres, persistance/clamp, migration des + anciennes préférences, arrêt/libération, rechargement, caméra et vue oblique + avec ombres. Captures françaises/anglaises inspectées ; suppression ensuite + d'un préfixe redondant dans le libellé de portée. +- Soleil, lune, nouvelle lune, ombres opaques nettes à midi et transmission + du feuillage restent vérifiés. Casters hors écran et retrait/repose actifs. +- 2 000 petits pas solaires aux coordonnées ±100 000 vérifient l'absence de + saut de translation de la carte ; changement d'ancrage par texels entiers. + La séquence du vrai service realtime suit 294 images : angle avancé de + 0,000694 radian, aucun rebond dans les 65 664 échantillons de sol suivis. + Sur cette courte séquence, aucune case opaque n'a changé ; cela ne constitue + pas une mesure de mouvement visible sur une journée entière. + +La fixture initiale capturait parfois le terrain avant la fin de sa construction +native : elle attend désormais la réception de toute la plateforme et la fin +compilée des sections. Aucun changement de gameplay pour contourner ce problème. + +Commande : `./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true +-PsanctuaryPixelShadows122ClientTests=true -PsanctuaryClientNoVsync=true +-PsanctuaryQuickTests=true`. Log `build/shader129-client.log`, captures sous +`mods/sanctuary/build/run/clientGameTest/screenshots/`. + +`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest` +réussit en **2 min 17 s**, 126 tâches. Une dernière passe d’assemblage intègre +la simplification du libellé de portée (2 min 18 s). Les archives sont vérifiées : +**17 entrées de production** diffèrent de beta.128, limitées au shader et à ses +interfaces. Même JAR dans normal/Test, sources archivées conformes, ressources +du template exactes, assets des gemmes inchangés. Aucun monde ni test embarqué. +Le serveur GameTest dédié +reste exclu conformément au refus antérieur de son EULA. Aucun monde personnel +utilisé pour les essais, aucun résultat Windows/Vulkan revendiqué. + + +Archives vérifiées dans `build/` et recopiées dans le dossier de livraison +habituel `sanctuary-beta/build/` : + +- Normal : 10 353 946 octets, SHA-256 + `2c7735194faab8a699004e69ad543ce6e8fabdebe297b759bffd31fea2dcc2e8`. +- Test : 10 372 872 octets, SHA-256 + `cca572ee313d1d7e66ed745bb48bac62490998760d9b7bbfc7a1f6d60c430dce`. +- JAR Sanctuary : + `ca3e2ff4e0717a3a54c1d57478d43aade39d001b71c4eec745d76f010f7def54`. + +Publication et synchronisation à consigner après leur exécution. diff --git a/gradle.properties b/gradle.properties index bd53256..ed67a97 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.128 -pack_version=beta.128 +mod_version=beta.129 +pack_version=beta.129 resource_pack_version=beta.121 maven_group=fr.koka.sanctuary jei_version=30.32.0-sanctuary.3 diff --git a/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/shader/EdgeHighlights129ClientChecks.java b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/shader/EdgeHighlights129ClientChecks.java new file mode 100644 index 0000000..3721efc --- /dev/null +++ b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/shader/EdgeHighlights129ClientChecks.java @@ -0,0 +1,206 @@ +package fr.koka.sanctuary.client.shader; + +import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*; + +import java.nio.file.Files; +import java.util.ArrayList; +import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; +import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.Blocks; +import org.joml.Matrix4f; +import org.joml.Vector4f; + +/** Native GPU checks sharing the isolated flat-world fixture and its preference backup. */ +final class EdgeHighlights129ClientChecks { + static void run(ClientGameTestContext c, TestServerContext server) { + c.runOnClient(m -> preferences()); + server.runOnServer(s -> { + for (int x=10;x<=14;x++) for (int z=6;z<=10;z++) { + s.overworld().setBlockAndUpdate(new BlockPos(x,65,z), + (Math.floorMod(x,8)<4 ? Blocks.STONE : Blocks.OAK_PLANKS).defaultBlockState()); + } + }); + camera(c,server,12.5,72,8.5,60); + c.waitTicks(35); + shadows(c,false); + edge(c,false); + var off=capture(c,"edges-flat-off"); + c.waitTicks(6); + unchanged(off,capture(c,null),"Highlight OFF control is stable"); + edge(c,true); + var on=capture(c,"edges-flat-on-30"); + var low=measure(off,on); + check(low.brighter>100 && low.stable>low.samples*.55 && low.darker VanillaLight.setEdgeDistance(distance)); + frames(c); + check(c.computeOnClient(m -> VanillaLight.edgeDistance())==distance,"All CTM ranges are available"); + unchanged(on,capture(c,null),"Near CTM contours unchanged at range "+distance); + } + c.runOnClient(m -> VanillaLight.setEdgeDistance(32)); + c.runOnClient(m -> { + check(PixelShadows.shadowMap()==null,"Highlight works without allocating a cast-shadow map"); + VanillaLight.setEdgeIntensity(90); + }); + frames(c); + var high=measure(off,capture(c,"edges-flat-on-90")); + check(high.meanGain>low.meanGain*1.8,"Intensity changes visible brightness: low="+low+" high="+high); + c.runOnClient(m -> VanillaLight.setEdgeIntensity(0)); + c.waitTicks(6); + c.runOnClient(m -> check(!EdgeHighlights.applied() && !EdgeHighlights.allocated(),"Zero intensity frees its uniform buffer")); + unchanged(off,capture(c,"edges-zero"),"Zero intensity reproduces OFF exactly"); + c.runOnClient(m -> VanillaLight.setEdgeIntensity(30)); + frames(c); + camera(c,server,11.99999,72,8.5,60); + var anchor=capture(c,null); + camera(c,server,12.00001,72,8.5,60); + unchanged(anchor,capture(c,"edges-anchor-crossing"),"Grid remains stable across an integer camera anchor"); + camera(c,server,12.5,72,8.5,60); + unchanged(on,capture(c,null),"Returning to the camera restores the same grid"); + var reload=c.computeOnClient(net.minecraft.client.Minecraft::reloadResourcePacks); + c.waitFor(m -> reload.isDone() && m.gui.overlay()==null,1200); + reload.join(); + c.runOnClient(m -> { + forgetPreferences(); + check(VanillaLight.edgeHighlights() && VanillaLight.edgeIntensity()==30,"Highlight preferences survive reload and disk reread"); + }); + frames(c); + unchanged(on,capture(c,"edges-after-reload"),"Resource reload retains the highlight image"); + edge(c,false); + unchanged(off,capture(c,"edges-off-restored"),"Disabling leaves no residual highlight"); + + // A new hole creates a connected boundary immediately; filling it removes that boundary. + server.runOnServer(s -> s.overworld().setBlockAndUpdate(new BlockPos(12,65,8),Blocks.AIR.defaultBlockState())); + c.waitTicks(20); + var holeOff=capture(c,null); + edge(c,true); + var hole=measure(holeOff,capture(c,"edges-ctm-hole")); + check(hole.brighter>low.brighter+20,"Removing an interior block creates a new CTM contour"); + server.runOnServer(s -> s.overworld().setBlockAndUpdate(new BlockPos(12,65,8),Blocks.STONE.defaultBlockState())); + c.waitTicks(20); + unchanged(on,capture(c,null),"Filling the hole reconnects the flat surface"); + // The actual radius gates a visible contour, independently of shadow range. + camera(c,server,12.5,88,8.5,30); + edge(c,false); + var farOff=capture(c,null); + c.runOnClient(m -> VanillaLight.setEdgeDistance(16)); + edge(c,true); + unchanged(farOff,capture(c,"edges-beyond-16"),"CTM beyond selected 16-block radius is absent"); + c.runOnClient(m -> {VanillaLight.setEdgeDistance(64);VanillaLight.setEdgeIntensity(90);}); + frames(c); + var far=measure(farOff,capture(c,"edges-inside-64")); + check(far.brighter>40,"Extending the CTM radius restores a distant contour: "+far); + c.runOnClient(m -> {VanillaLight.setEdgeDistance(32);VanillaLight.setEdgeIntensity(30);}); + edge(c,false); + + server.runOnServer(s -> { + for (int x=9;x<=13;x+=2) for(int y=65;y<=67;y++) + s.overworld().setBlockAndUpdate(new BlockPos(x,y,12), + (x==9 ? Blocks.CONCRETE.red() : x==11 ? Blocks.STONE_BRICKS : Blocks.OAK_PLANKS).defaultBlockState()); + s.overworld().setBlockAndUpdate(new BlockPos(10,65,11),Blocks.STONE_SLAB.defaultBlockState()); + s.overworld().setBlockAndUpdate(new BlockPos(12,65,11),Blocks.OAK_STAIRS.defaultBlockState()); + }); + camera(c,server,12.5,72,7.5,70); + look(c,server,15,55); + shadows(c,true); + var reliefOff=capture(c,"edges-relief-with-shadows-off"); + edge(c,true); + var reliefOn=capture(c,"edges-relief-with-shadows-on"); + var relief=measure(reliefOff,reliefOn); + check(relief.brighter>100 && relief.stable>relief.samples*.5 && relief.darker VanillaLight.setEnabled(false)); + c.waitTicks(6); + c.runOnClient(m -> check(!EdgeHighlights.applied() && !EdgeHighlights.allocated(),"Main shader OFF also releases highlight resources")); + c.runOnClient(m -> VanillaLight.setEnabled(true)); + frames(c); + edge(c,false); + System.out.println("EDGE_HIGHLIGHTS129_PASS connected contours and unchanged flat joins; independent shadows; intensity; zero/OFF release; " + +"camera anchors; oblique textured scene; resource reload; legacy preference migration; low="+low+" high="+high+" relief="+relief); + } + + private static void preferences() { + var path=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json"); + try { + // Real prior-version JSON, without either new field. Preserve its existing settings. + Files.writeString(path,"{\"enabled\":true,\"saturation\":100,\"pixelShadows\":true,\"shadowPixels\":16,\"shadowDistance\":32}"); + forgetPreferences(); + check(!VanillaLight.edgeHighlights() && VanillaLight.edgeIntensity()==30 && VanillaLight.edgeDistance()==32,"Old configuration defaults highlights OFF at intensity 30"); + check(VanillaLight.pixelShadows() && VanillaLight.saturation()==100,"Migration retains existing shader choices"); + for(int value:new int[]{-100,0,30,100,1000}) { + VanillaLight.setEdgeIntensity(value);forgetPreferences(); + check(VanillaLight.edgeIntensity()==Math.clamp(value,0,100),"Saved intensity is bounded"); + } + for(int[] pair:new int[][]{{-1,16},{24,32},{48,64},{96,128},{192,256},{9999,256}}) { + VanillaLight.setEdgeDistance(pair[0]);forgetPreferences(); + check(VanillaLight.edgeDistance()==pair[1],"Saved CTM distance is bounded and normalized"); + } + VanillaLight.setEdgeDistance(32); + VanillaLight.setEdgeIntensity(30); + VanillaLight.setEdgeHighlights(true);forgetPreferences(); + check(VanillaLight.edgeHighlights(),"Toggle persists independently"); + VanillaLight.setEdgeHighlights(false); + } catch(java.io.IOException e) { throw new AssertionError(e); } + } + + private static void edge(ClientGameTestContext c,boolean enabled) { + c.runOnClient(m -> VanillaLight.setEdgeHighlights(enabled)); + if(enabled)frames(c); + else { + c.waitTicks(6); + c.runOnClient(m -> check(!EdgeHighlights.applied() && !EdgeHighlights.allocated(),"OFF frees resources")); + } + } + + private static void frames(ClientGameTestContext c) { + int before=c.computeOnClient(m -> EdgeHighlights.renderedFrames()); + c.waitFor(m -> EdgeHighlights.applied() && EdgeHighlights.renderedFrames()>before+8,200); + c.waitTicks(6); + } + + private record Difference(int samples,int brighter,int stable,int darker,double meanGain) { } + private static Difference measure(Frame off,Frame on) { + int samples=0,brighter=0,stable=0,darker=0;double gain=0; + for(int y=off.height()/10;y=4)brighter++; + if(delta<=-4)darker++; + if(channelDifference(a,b)<=2)stable++; + } + var result=new Difference(samples,brighter,stable,darker,gain/samples); + System.out.println("EDGE_HIGHLIGHTS129_PIXELS "+result); + return result; + } + + private static void grid(ClientGameTestContext c,Frame off,Frame on) { + var samples=c.computeOnClient(m -> { + var camera=m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState; + var vp=new Matrix4f(camera.projectionMatrix).mul(camera.viewRotationMatrix); + var result=new ArrayList(); + for(int x=11;x<=14;x++) for(int z=6;z<=10;z++) for(int edge=0;edge<2;edge++) { + var p=vp.transform(new Vector4f((float)(x+(edge==0?.5:1.0/32)-camera.pos.x), + (float)(66-camera.pos.y),(float)(z+.5-camera.pos.z),1)); + int px=(int)((p.x/p.w*.5+.5)*off.width()); + int py=(int)((.5-p.y/p.w*.5)*off.height()); + if(px>=10 && px=10 && py=4)bright++;} + } + check(centers>=15 && unchanged>centers*.95 && edges>=15 && bright==0, + "Connected block centers and coplanar joins remain untouched: centers="+unchanged+"/"+centers+" edges="+bright+"/"+edges); + System.out.println("EDGE_HIGHLIGHTS129_GRID centers="+unchanged+"/"+centers+" edges="+bright+"/"+edges); + } +} 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 index e8b6751..4a330d2 100644 --- 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 @@ -121,6 +121,11 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest { c.waitFor(m -> m.level.getBlockState(new BlockPos(0, FLOOR + 8, 0)).is(Blocks.CONCRETE.red()) && m.level.getBlockState(SLAB).is(Blocks.RED_SANDSTONE_SLAB) && m.level.getBlockState(STAIR).is(Blocks.RED_SANDSTONE_STAIRS), 200); + c.waitFor(m -> { + for(int x=-24;x<=24;x+=8) for(int z=-24;z<=24;z+=8) + if(!m.level.getBlockState(new BlockPos(x,FLOOR,z)).is(Blocks.CONCRETE.white()))return false; + return m.levelRenderer.hasRenderedAllSections(); + },1200); c.waitTicks(40); c.runOnClient(m -> check(VanillaLight.active(), "Native Sanctuary shader is active without an external engine")); float morning = c.computeOnClient(m -> m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle); @@ -147,7 +152,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest { "Live pixel density reallocates the correct depth map")); capture(c, "precision-" + pixels); } - for (int distance : new int[]{16, 64, 32}) { + for (int distance : new int[]{16, 64, 128, 256, 32}) { c.runOnClient(m -> VanillaLight.setShadowDistance(distance)); activeFrames(c); c.runOnClient(m -> check(PixelShadows.shadowMap().width == PixelShadows.mapSize(distance, 16), @@ -229,12 +234,14 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest { }); activeFrames(c); unchanged(beforeReload, capture(c, "after-reload"), "Resource reload preserves the rendered ground shadow"); + RealtimeShadows129ClientChecks.run(c, server); + EdgeHighlights129ClientChecks.run(c, server); c.getInput().pressKey(InputConstants.KEY_F1); for (String locale : new String[]{"fr_fr", "en_us"}) { c.runOnClient(m -> { m.getLanguageManager().setSelected(locale); m.getLanguageManager().onResourceManagerReload(m.getResourceManager()); - for (String key : new String[]{"pixel_shadows", "shadow_pixels", "shadow_distance"}) { + for (String key : new String[]{"pixel_shadows", "shadow_pixels", "shadow_distance", "edge_highlights", "edge_intensity", "edge_distance"}) { String fullKey = "sanctuary.shaders." + key; check(!Component.translatable(fullKey).getString().equals(fullKey), "Translated " + locale + " " + key); } @@ -246,12 +253,12 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest { }); }); c.waitTicks(3); - c.takeScreenshot("sanctuary-beta128-shadows-options-" + locale); + c.takeScreenshot("sanctuary-beta129-shadows-options-" + locale); } c.runOnClient(m -> m.gui.setScreen(null)); } System.out.println("PIXEL_SHADOWS122_CLIENT_PASS: bounded saved preferences; native GPU ON/OFF ground; " - + "offscreen caster; live removal/replacement; subtle moon; noon attenuation; foliage density; camera stability; resource reload; FR/EN screenshots"); + + "offscreen caster; live removal/replacement; subtle moon; full-strength noon; foliage density; camera stability; resource reload; FR/EN screenshots"); } finally { onboarding.set(false); c.runOnClient(m -> { @@ -287,8 +294,8 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest { var noonOn = capture(c, "noon-on"); var noonStats = opacity(noonOff, noonOn); crispEdge(noonOff, noonOn); - check(noonStats.dark > 100 && noonStats.maximum > .04 && noonStats.maximum < .14, - "Noon shadow is present but attenuated: " + noonStats); + check(noonStats.dark > 100 && noonStats.maximum > .34 && noonStats.maximum < .42, + "Noon shadow keeps its full daytime strength: " + noonStats); camera(c, server, 1.00001, FLOOR + 5, 1, 70); unchanged(noonOn, capture(c, "noon-camera-subpixel"), "Noon tiny motion across former integer anchor"); camera(c, server, .99999, FLOOR + 5, 1, 70); @@ -379,7 +386,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest { if (!whiteGround(off.pixels[i])) continue; samples++; double ratio=(brightness(off.pixels[i])-brightness(on.pixels[i]))/(double)brightness(off.pixels[i]); - if (ratio > .015 && ratio < .070) intermediate++; + if (ratio > .015 && ratio < .32) intermediate++; } check(samples > 1000 && intermediate < samples / 200, "Opaque noon edge stays crisp, no foliage smoothing: " + intermediate + "/" + samples); @@ -412,13 +419,14 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest { private static void checkPreferences() { VanillaLight.setEnabled(true); + VanillaLight.setEdgeHighlights(false); VanillaLight.setSaturation(100); for (int[] pair : new int[][]{{Integer.MIN_VALUE, 8}, {11, 8}, {12, 16}, {16, 16}, {23, 16}, {24, 32}, {Integer.MAX_VALUE, 32}}) { VanillaLight.setShadowPixels(pair[0]); forgetPreferences(); check(VanillaLight.shadowPixels() == pair[1], "Persisted pixel density rounds/clamps " + pair[0]); } - for (int[] pair : new int[][]{{Integer.MIN_VALUE, 16}, {23, 16}, {24, 32}, {32, 32}, {47, 32}, {48, 64}, {Integer.MAX_VALUE, 64}}) { + for (int[] pair : new int[][]{{Integer.MIN_VALUE, 16}, {23, 16}, {24, 32}, {32, 32}, {47, 32}, {48, 64}, {95,64}, {96,128}, {191,128}, {192,256}, {256,256}, {Integer.MAX_VALUE, 256}}) { VanillaLight.setShadowDistance(pair[0]); forgetPreferences(); check(VanillaLight.shadowDistance() == pair[1], "Persisted shadow distance rounds/clamps " + pair[0]); @@ -432,7 +440,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest { VanillaLight.setShadowDistance(32); } - private static void forgetPreferences() { + static void forgetPreferences() { try { var field = VanillaLight.class.getDeclaredField("preferences"); field.setAccessible(true); @@ -442,7 +450,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest { } } - private static void camera(ClientGameTestContext c, TestServerContext server, double x, double y, double z, int fov) { + static void camera(ClientGameTestContext c, TestServerContext server, double x, double y, double z, int fov) { server.runOnServer(s -> { var p = s.getPlayerList().getPlayers().getFirst(); command(s, "tp @a " + x + " " + y + " " + z + " 0 90"); @@ -461,7 +469,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest { c.waitTicks(15); } - private static void look(ClientGameTestContext c, TestServerContext server, float yaw, float pitch) { + static void look(ClientGameTestContext c, TestServerContext server, float yaw, float pitch) { server.runOnServer(s -> { var p = s.getPlayerList().getPlayers().getFirst(); command(s, "tp @a " + p.getX() + " " + p.getY() + " " + p.getZ() + " " + yaw + " " + pitch); @@ -478,7 +486,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest { } } - private static void command(MinecraftServer s, String command) { + static void command(MinecraftServer s, String command) { try { // Dispatcher exceptions fail the fixture instead of leaving an ignored command error in chat. s.getCommands().getDispatcher().execute(command, s.createCommandSourceStack()); @@ -487,7 +495,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest { } } - private static void shadows(ClientGameTestContext c, boolean enabled) { + static void shadows(ClientGameTestContext c, boolean enabled) { c.runOnClient(m -> VanillaLight.setPixelShadows(enabled)); if (enabled) activeFrames(c); else { @@ -501,15 +509,15 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest { } } - private static void activeFrames(ClientGameTestContext c) { + static void activeFrames(ClientGameTestContext c) { int before = c.computeOnClient(m -> PixelShadows.renderedFrames()); c.waitFor(m -> PixelShadows.applied() && PixelShadows.ready() && PixelShadows.renderedFrames() > before + 12, 200); // Allow the bounded terrain cache and native light/chunk uploads to settle before reading pixels. c.waitTicks(30); } - private static Frame capture(ClientGameTestContext c, String name) { - if (name != null) c.takeScreenshot("sanctuary-beta128-shadows-" + name); + static Frame capture(ClientGameTestContext c, String name) { + if (name != null) c.takeScreenshot("sanctuary-beta129-shadows-" + name); var result = new CompletableFuture(); c.runOnClient(m -> Screenshot.takeScreenshot(m.gameRenderer.mainRenderTarget(), image -> { try (image) { @@ -522,7 +530,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest { return result.join(); } - private record Frame(int width, int height, int[] pixels) { } + record Frame(int width, int height, int[] pixels) { } private record Difference(int sampled, int ground, int dark, int stable, int brighter) { } private static Difference compare(Frame before, Frame after) { @@ -552,7 +560,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest { check(d.brighter < Math.max(12, d.ground / 100), label + ": shadows must not brighten ground: " + d); } - private static void unchanged(Frame before, Frame after, String label) { + static void unchanged(Frame before, Frame after, String label) { sameSize(before, after); int sampled = 0, changed = 0; for (int y = before.height / 10; y < before.height * 9 / 10; y += 2) { @@ -595,11 +603,11 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest { return Math.min(r, Math.min(g, b)) > 100 && Math.max(r, Math.max(g, b)) - Math.min(r, Math.min(g, b)) < 24; } - private static int brightness(int color) { + static int brightness(int color) { return ((color >> 16 & 255) + (color >> 8 & 255) + (color & 255)) / 3; } - private static int channelDifference(int a, int b) { + static int channelDifference(int a, int b) { return Math.max(Math.abs((a >> 16 & 255) - (b >> 16 & 255)), Math.max(Math.abs((a >> 8 & 255) - (b >> 8 & 255)), Math.abs((a & 255) - (b & 255)))); } @@ -630,5 +638,5 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest { } private static KeyEvent enter() { return new KeyEvent(InputConstants.KEY_RETURN, 0, 0); } - private static void check(boolean ok, String message) { if (!ok) throw new AssertionError(message); } + static void check(boolean ok, String message) { if (!ok) throw new AssertionError(message); } } diff --git a/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/shader/RealtimeShadows129ClientChecks.java b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/shader/RealtimeShadows129ClientChecks.java new file mode 100644 index 0000000..7b4b0b5 --- /dev/null +++ b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/shader/RealtimeShadows129ClientChecks.java @@ -0,0 +1,115 @@ +package fr.koka.sanctuary.client.shader; + +import static fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks.*; + +import fr.koka.sanctuary.realtime.RealtimeService; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; +import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext; +import net.fabricmc.fabric.api.client.rendering.v1.level.LevelRenderEvents; + +final class RealtimeShadows129ClientChecks { + private static void projectionContinuity() { + // Far coordinates amplify the old per-frame rounding failure. Moving the sun must not + // add a periodic translation to the shadow raster while the camera remains stationary. + var grid=new ShadowProjection(); + float last=0; int jumps=0; float greatest=0; + for(int i=0;i<2000;i++) { + var matrix=grid.matrix(PixelShadows.sunDirection(.7f+i*.000001f),32,2048,100000,64,-100000,false); + if(i>0){float step=Math.abs(matrix.m31()-last);greatest=Math.max(greatest,step);if(step>1e-6f)jumps++;} + last=matrix.m31(); + } + check(jumps==0,"Slow light rotation has no cyclical raster translation far from spawn: "+greatest); + // Recentring transfers the phase modulo integral texels, preserving the sampled grid. + var sun=PixelShadows.sunDirection(.71f); + var before=grid.matrix(sun,32,2048,100000,64,-100000,false); + var after=grid.matrix(sun,32,2048,100016,64,-100000,false); + var a=before.transform(new org.joml.Vector4f(4,1,2,1)); + var b=after.transform(new org.joml.Vector4f(-12,1,2,1)); + for(float value:new float[]{(b.x-a.x)*1024,(b.y-a.y)*1024}) + check(Math.abs(value-Math.rint(value))<.001,"Camera recentering moves only whole shadow texels"); + } + + static void run(ClientGameTestContext c, TestServerContext server) { + projectionContinuity(); + var enabled=new AtomicBoolean(false); + var trace=Collections.synchronizedList(new ArrayList()); + LevelRenderEvents.END_EXTRACTION.register(context -> { + if(enabled.get() && trace.size()<4096)trace.add(new double[]{System.nanoTime()/1e9,context.levelState().skyRenderState.sunAngle}); + }); + var original=new AtomicReference(); + try { + server.runOnServer(s -> { + var session=RealtimeService.session(s); + check(session!=null,"Isolated world has the actual real-time service"); + try { + var field=session.getClass().getDeclaredField("clock");field.setAccessible(true); + original.set((Clock)field.get(session)); + var morning=session.sample().day().noon().minusSeconds(3*3600); + field.set(session,Clock.offset(Clock.systemUTC(),Duration.between(Instant.now(),morning))); + check(RealtimeService.setMode(s,true),"Real-time mode enabled through actual server service"); + } catch(ReflectiveOperationException e) {throw new AssertionError(e);} + catch(Exception e) {throw new AssertionError(e);} + }); + c.waitFor(m -> PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle).y>.2,200); + c.waitTicks(40); + double shadowX=c.computeOnClient(m -> { + var sun=PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle); + return 1-7*sun.x/sun.y; + }); + camera(c,server,shadowX,69,1,30); + shadows(c,false); + var off=capture(c,null); + shadows(c,true); + enabled.set(true); + var frames=new ArrayList(); + for(int i=0;i<24;i++) {c.waitTicks(6);frames.add(capture(c,i==0?"realtime-start":i==23?"realtime-end":null));} + c.runOnClient(m -> enabled.set(false)); + double travel=Math.IEEEremainder(trace.getLast()[1]-trace.getFirst()[1],Math.PI*2); + int reverse=0;double largest=0; + for(int i=1;i>16&255,g=a>>8&255,b=a&255; + if(Math.min(r,Math.min(g,b))<100 || Math.max(r,Math.max(g,b))-Math.min(r,Math.min(g,b))>24)continue; + samples++;int flips=0; + boolean last=brightness(a)-brightness(frames.getFirst().pixels()[index])>=8; + for(var frame:frames) { + boolean dark=brightness(a)-brightness(frame.pixels()[index])>=8; + if(last!=dark)flips++; + last=dark; + } + if(flips>0)changed++; + if(flips>1)rebound++; + } + check(trace.size()>30 && Math.abs(travel)>.00001,"Real-time shadows actually remain dynamic"); + check(rebound { + try { + RealtimeService.setMode(s,false); + if(original.get()!=null){var session=RealtimeService.session(s);var field=session.getClass().getDeclaredField("clock");field.setAccessible(true);field.set(session,original.get());} + command(s,"time set 3000"); + }catch(Exception e){throw new AssertionError(e);} + }); + } + } +} 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 b578d51..d15f58d 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 @@ -20,6 +20,7 @@ public final class SanctuaryKnowledgeClient implements ClientModInitializer { SanctuaryResourcePack.register(); VortexCloudRenderer.register(); fr.koka.sanctuary.client.shader.PixelShadows.register(); + fr.koka.sanctuary.client.shader.EdgeHighlights.register(); ProgressionClient.register(); StarClient.register(); WeatherClient.register(); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/EdgeHighlights.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/EdgeHighlights.java new file mode 100644 index 0000000..a72e2ba --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/EdgeHighlights.java @@ -0,0 +1,99 @@ +package fr.koka.sanctuary.client.shader; + +import com.mojang.blaze3d.buffers.Std140Builder; +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.renderpearl.api.GpuFormat; +import com.mojang.renderpearl.api.buffers.GpuBuffer; +import com.mojang.renderpearl.api.pipeline.*; +import com.mojang.renderpearl.api.textures.FilterMode; +import fr.koka.sanctuary.client.panorama.PanoramaCapture; +import java.util.Optional; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.MappableRingBuffer; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.material.FogType; +import org.joml.Matrix4f; + +/** Optional, texture-preserving highlights on visible voxel edges. No gameplay light changes. */ +public final class EdgeHighlights { + // Destination * (1 + highlight): retain the block's existing colour and texture, including shadows. + private static final RenderPipeline APPLY = RenderPipelines.register(RenderPipeline.builder() + .withLocation(Identifier.parse("sanctuary:pipeline/edge_highlights")) + .withVertexShader("core/screenquad") + .withFragmentShader(Identifier.parse("sanctuary:core/edge_highlights")) + .withBindGroupLayout(BindGroupLayout.builder() + .withUniform("EdgeHighlights", UniformType.UNIFORM_BUFFER) + .withUniform("WorldDepth", UniformType.COMBINED_IMAGE_SAMPLER).build()) + .withPrimitiveTopology(PrimitiveTopology.TRIANGLES).withCull(false) + .withColorTargetState(new ColorTargetState(Optional.of(new BlendFunction( + BlendFactor.DST_COLOR, BlendFactor.ONE, BlendFactor.ZERO, BlendFactor.ONE)), + GpuFormat.RGBA8_UNORM, ColorTargetState.WRITE_COLOR)) + .withDepthStencilState(Optional.empty()).build()); + private static final Matrix4f projection = new Matrix4f(); + private static MappableRingBuffer uniforms; + private static boolean applied; + private static int renderedFrames; + + private EdgeHighlights() { } + + public static void register() { + ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> release()); + ClientLifecycleEvents.CLIENT_STOPPING.register(client -> release()); + } + + public static void captureProjection(Matrix4f matrix) { projection.set(matrix); } + + /** Runs after cast shadows and before the hand and HUD; works without the shadow depth map. */ + public static void render() { + applied = false; + var client = Minecraft.getInstance(); + if (!VanillaLight.active() || !VanillaLight.edgeHighlights() || VanillaLight.edgeIntensity() == 0 + || client.level == null) { + release(); + return; + } + if (PanoramaCapture.rendering()) return; + var camera = client.gameRenderer.gameRenderState().levelRenderState.cameraRenderState; + if (camera.fogType != FogType.NONE) return; + if (uniforms == null) uniforms = new MappableRingBuffer(() -> "Sanctuary edge highlights", + GpuBuffer.USAGE_UNIFORM | GpuBuffer.USAGE_MAP_WRITE, 176); + var viewProjection = new Matrix4f(projection).mul(camera.viewRotationMatrix); + var inverse = new Matrix4f(viewProjection).invert(); + var fog = camera.fogData; + uniforms.rotate(); + try (var mapped = uniforms.currentBuffer().map(false, true)) { + Std140Builder.intoBuffer(mapped.data()).putMat4f(inverse).putMat4f(viewProjection) + .putVec4((float)(camera.pos.x - Math.floor(camera.pos.x)), + (float)(camera.pos.y - Math.floor(camera.pos.y)), + (float)(camera.pos.z - Math.floor(camera.pos.z)), 16) + .putVec4(VanillaLight.edgeIntensity() * .004f, + RenderSystem.getDevice().getDeviceInfo().isZZeroToOne() ? 1 : 0, + fog.renderDistanceStart, fog.renderDistanceEnd) + .putVec4(fog.environmentalStart, fog.environmentalEnd, fog.color.w, VanillaLight.edgeDistance()); + } + var main = client.gameRenderer.mainRenderTarget(); + var encoder = RenderSystem.getDevice().createCommandEncoder(); + try (var pass = encoder.createRenderPass(() -> "Sanctuary edge highlights", + main.getColorTextureView(), Optional.empty())) { + pass.setPipeline(RenderSystem.getCompiledPipeline(APPLY)); + pass.setUniform("EdgeHighlights", uniforms.currentBuffer()); + pass.setUniform("WorldDepth", main.getDepthTextureView(), + RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST)); + pass.draw(3, 1, 0, 0); + } + renderedFrames++; + applied = true; + } + + public static void release() { + if (uniforms != null) { uniforms.close(); uniforms = null; } + applied = false; + } + + static boolean applied() { return applied; } + static boolean allocated() { return uniforms != null; } + static int renderedFrames() { return renderedFrames; } +} 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 index 6f59a66..b8d991c 100644 --- 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 @@ -51,6 +51,7 @@ public final class PixelShadows { .withDepthStencilState(Optional.empty()).build()); private static final ShadowTerrain TERRAIN = new ShadowTerrain(); private static final Matrix4f projection = new Matrix4f(); + private static final ShadowProjection lightGrid = new ShadowProjection(); private static TextureTarget shadowMap; private static MappableRingBuffer uniforms; private static int renderedFrames; @@ -86,7 +87,7 @@ public final class PixelShadows { if (lunar) sun = sunDirection(sky.moonAngle); if (sky.skybox != DimensionType.Skybox.OVERWORLD || camera.fogType != FogType.NONE || sun.y <= .08f) return; float moon = sky.moonPhase == null ? 1 : (float)(.5 + .5 * Math.cos(sky.moonPhase.index() * Math.PI / 4)); - float strength = (lunar ? .10f * moon : .38f * zenithFade(sun.y)) + float strength = (lunar ? .10f * moon : .38f) * Math.clamp((sun.y - .08f) / .16f, 0, 1) * sky.rainBrightness; if (strength <= .001f) return; int distance = VanillaLight.shadowDistance(), pixels = VanillaLight.shadowPixels(); @@ -100,7 +101,7 @@ public final class PixelShadows { GpuBuffer.USAGE_UNIFORM | GpuBuffer.USAGE_MAP_WRITE, 192); double ox = anchor(camera.pos.x), oy = anchor(camera.pos.y), oz = anchor(camera.pos.z); boolean zeroToOne = RenderSystem.getDevice().getDeviceInfo().isZZeroToOne(); - var light = lightMatrix(sun, distance, size, ox, oy, oz, zeroToOne); + var light = lightGrid.matrix(sun, distance, size, ox, oy, oz, zeroToOne); var inverse = new Matrix4f(projection).mul(camera.viewRotationMatrix).invert(); uniforms.rotate(); var fog = camera.fogData; @@ -152,12 +153,6 @@ public final class PixelShadows { applied = true; } - /** Smoothly soften overhead sunlight, without a noon on/off discontinuity. */ - static float zenithFade(float elevation) { - float t = Math.clamp((elevation - .92f) / .08f, 0, 1); - return 1 - .75f * t * t * (3 - 2 * t); - } - static double anchor(double coordinate) { return Math.floor(coordinate / 16.0 + .5) * 16.0; } static Vector3f sunDirection(float angle) { return new Vector3f(-(float)Math.sin(angle), (float)Math.cos(angle), 0); } static int mapSize(int distance, int pixels) { @@ -165,20 +160,9 @@ public final class PixelShadows { while (size < 2 * (distance + 16) * pixels && size < 4096) size *= 2; return size; } - static Matrix4f lightMatrix(Vector3f sun, int distance, int size, double x, double y, double z, boolean zeroToOne) { - float extent = distance + 16, reach = 4 * (distance + 16); - // A fixed up axis is nonsingular even at noon. Snap the light-space origin to texels. - var view = new Matrix4f().lookAt(sun.x * reach, sun.y * reach, sun.z * reach, 0, 0, 0, 0, 0, 1); - double texel = 2.0 * extent / size; - double sx = view.m00() * x + view.m10() * y + view.m20() * z; - double sy = view.m01() * x + view.m11() * y + view.m21() * z; - var result = new Matrix4f().setOrtho(-extent, extent, -extent, extent, .1f, reach * 2, zeroToOne).mul(view); - result.m30(result.m30() + (float)((sx - Math.rint(sx / texel) * texel) / extent)); - result.m31(result.m31() + (float)((sy - Math.rint(sy / texel) * texel) / extent)); - return result; - } public static void release() { TERRAIN.close(); + lightGrid.reset(); if (shadowMap != null) { shadowMap.destroyBuffers(); shadowMap = null; } if (uniforms != null) { uniforms.close(); uniforms = null; } applied = false; 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 200374e..d03bda6 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 @@ -17,6 +17,15 @@ 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.edge_highlights",OptionInstance.cachedConstantTooltip( + Component.translatable("sanctuary.shaders.edge_highlights_help")), + VanillaLight.edgeHighlights(),VanillaLight::setEdgeHighlights)); + list.addBig(new OptionInstance("sanctuary.shaders.edge_intensity",OptionInstance.cachedConstantTooltip( + Component.translatable("sanctuary.shaders.edge_intensity_help")),(caption,value)->Component.translatable("sanctuary.shaders.edge_intensity_value",value), + new OptionInstance.IntRange(0,100),VanillaLight.edgeIntensity(),VanillaLight::setEdgeIntensity)); + list.addBig(new OptionInstance("sanctuary.shaders.edge_distance",OptionInstance.cachedConstantTooltip( + Component.translatable("sanctuary.shaders.edge_distance_help")),(caption,value)->Component.translatable("sanctuary.shaders.edge_distance_value",value), + new OptionInstance.Enum<>(List.of(16,32,64,128,256),Codec.INT),VanillaLight.edgeDistance(),VanillaLight::setEdgeDistance)); list.addBig(OptionInstance.createBoolean("sanctuary.shaders.pixel_shadows",OptionInstance.cachedConstantTooltip( Component.translatable("sanctuary.shaders.pixel_shadows_help")), VanillaLight.pixelShadows(),VanillaLight::setPixelShadows)); @@ -25,6 +34,6 @@ public final class ShaderOptionsScreen extends OptionsSubScreen { 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)); + new OptionInstance.Enum<>(List.of(16,32,64,128,256),Codec.INT),VanillaLight.shadowDistance(),VanillaLight::setShadowDistance)); } } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/ShadowProjection.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/ShadowProjection.java new file mode 100644 index 0000000..886c23a --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/shader/ShadowProjection.java @@ -0,0 +1,37 @@ +package fr.koka.sanctuary.client.shader; + +import org.joml.Matrix4f; +import org.joml.Vector3f; + +/** Keeps the raster grid continuous as the sun moves; only camera recentering snaps to texels. */ +final class ShadowProjection { + private boolean initialized; + private int distance, size; + private double x, y, z, phaseX, phaseY; + + Matrix4f matrix(Vector3f sun, int distance, int size, double x, double y, double z, boolean zeroToOne) { + float extent = distance + 16, reach = 4 * (distance + 16); + var view = new Matrix4f().lookAt(sun.x * reach, sun.y * reach, sun.z * reach, + 0, 0, 0, 0, 0, 1); + double texel = 2.0 * extent / size; + if (!initialized || this.distance != distance || this.size != size) { + phaseX = phaseY = 0; + } else if (this.x != x || this.y != y || this.z != z) { + double dx = x - this.x, dy = y - this.y, dz = z - this.z; + phaseX = Math.IEEEremainder(phaseX + view.m00() * dx + view.m10() * dy + view.m20() * dz, texel); + phaseY = Math.IEEEremainder(phaseY + view.m01() * dx + view.m11() * dy + view.m21() * dz, texel); + } + // Re-rounding the projected WORLD origin every frame made a slowly rotating light jump + // a raster texel periodically, particularly far from spawn. Retain this phase in between + // camera anchor changes: the light can rotate continuously without moving the raster origin. + initialized = true; + this.distance = distance; this.size = size; + this.x = x; this.y = y; this.z = z; + var result = new Matrix4f().setOrtho(-extent, extent, -extent, extent, .1f, reach * 2, zeroToOne).mul(view); + result.m30(result.m30() + (float)(phaseX / extent)); + result.m31(result.m31() + (float)(phaseY / extent)); + return result; + } + + void reset() { initialized = false; } +} 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 index 920a8d3..3132031 100644 --- 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 @@ -39,8 +39,8 @@ import org.joml.Vector3fc; * A different ClientLevel, or update(null, ...), also releases all owned resources. */ public final class ShadowTerrain implements AutoCloseable { - /** Bounds metadata/work as well as geometry; current shader options use 16, 32 and 64. */ - public static final int MAX_DISTANCE = 128; + /** Bounds metadata/work as well as geometry; shader options reach 256 blocks. */ + public static final int MAX_DISTANCE = 256; private static final int MAX_SECTIONS_PER_UPDATE = 4; private static final long BUILD_NANOS = 3_000_000L; private static final long MAX_GPU_BYTES = 96L * 1024 * 1024; @@ -61,6 +61,8 @@ public final class ShadowTerrain implements AutoCloseable { private long gpuBytes; private List entries = List.of(); private boolean entriesChanged; + private long reconciledTick = Long.MIN_VALUE; + private boolean metadataDirty = true; private int cameraSectionX, cameraSectionY, cameraSectionZ; /** @@ -108,7 +110,12 @@ public final class ShadowTerrain implements AutoCloseable { cameraSectionX = cx; cameraSectionY = cy; cameraSectionZ = cz; - reconcile(); + // Large ranges must not rescan loaded chunk metadata at the display's frame rate. + if (moved || metadataDirty || reconciledTick != level.getGameTime()) { + reconcile(); + reconciledTick = level.getGameTime(); + metadataDirty = false; + } if (moved) { retryDeferred(); } @@ -172,11 +179,13 @@ public final class ShadowTerrain implements AutoCloseable { RenderSystem.assertOnRenderThread(); Section section = sections.get(SectionPos.asLong(sx, sy, sz)); if (section != null) dirty(section); + else metadataDirty = true; } /** Coordinates are chunk coordinates. Caller also invalidates adjacent chunks as needed. */ public void dirtyChunk(int cx, int cz) { RenderSystem.assertOnRenderThread(); + metadataDirty = true; for (Section section : sections.values()) { if (section.x == cx && section.z == cz) dirty(section); } @@ -406,6 +415,8 @@ public final class ShadowTerrain implements AutoCloseable { for (Section section : sections.values()) release(section); sections.clear(); chunks.clear(); + reconciledTick = Long.MIN_VALUE; + metadataDirty = true; shapes.clear(); entries = List.of(); entriesChanged = false; 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 46a5e4e..94cb56d 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 @@ -18,6 +18,9 @@ public final class VanillaLight { boolean enabled=true; int saturation=142; boolean pixelShadows=false; + boolean edgeHighlights=false; + int edgeIntensity=30; + int edgeDistance=32; int shadowPixels=16; int shadowDistance=32; } @@ -27,22 +30,30 @@ public final class VanillaLight { try{if(Files.isRegularFile(path()))preferences=new GsonBuilder().create().fromJson(Files.readString(path()),Preferences.class);} catch(Exception e){SanctuaryMod.LOGGER.warn("Cannot read Sanctuary shader preference",e);} if(preferences==null)preferences=new Preferences(); + preferences.edgeIntensity=Math.clamp(preferences.edgeIntensity,0,100); + preferences.edgeDistance=normalizeShadowDistance(preferences.edgeDistance); preferences.saturation=Math.clamp(preferences.saturation,0,200); preferences.shadowPixels=normalizeShadowValue(preferences.shadowPixels,8,16,32); - preferences.shadowDistance=normalizeShadowValue(preferences.shadowDistance,16,32,64); + preferences.shadowDistance=normalizeShadowDistance(preferences.shadowDistance); } return preferences.enabled; } public static boolean externalEngine(){return EXTERNAL_ENGINE;} public static boolean active(){return ProgressionClient.active()&&!EXTERNAL_ENGINE&&enabled();} public static int saturation(){enabled();return preferences.saturation;} + public static boolean edgeHighlights(){enabled();return preferences.edgeHighlights;} + public static int edgeDistance(){enabled();return preferences.edgeDistance;} + public static void setEdgeDistance(int value){enabled();preferences.edgeDistance=normalizeShadowDistance(value);save();} + public static int edgeIntensity(){enabled();return preferences.edgeIntensity;} + public static void setEdgeHighlights(boolean value){enabled();preferences.edgeHighlights=value;save();} + public static void setEdgeIntensity(int value){enabled();preferences.edgeIntensity=Math.clamp(value,0,100);save();} public static boolean 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 setShadowDistance(int value){enabled();preferences.shadowDistance=normalizeShadowDistance(value);save();} public static void setEnabled(boolean value){ enabled();preferences.enabled=value; save(); @@ -52,6 +63,12 @@ public final class VanillaLight { if(value<(low+middle)/2)return low; return value<(middle+high)/2?middle:high; } + private static int normalizeShadowDistance(int value){ + if(value<24)return 16; + if(value<48)return 32; + if(value<96)return 64; + return value<192?128:256; + } private static void save(){ try{ Files.createDirectories(path().getParent());var temporary=path().resolveSibling(path().getFileName()+".tmp"); 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 index 54921aa..0b94026 100644 --- 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 @@ -4,6 +4,7 @@ import com.llamalad7.mixinextras.injector.wrapoperation.Operation; import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; import com.mojang.renderpearl.api.buffers.GpuBufferSlice; import fr.koka.sanctuary.client.shader.PixelShadows; +import fr.koka.sanctuary.client.shader.EdgeHighlights; import net.minecraft.client.renderer.GameRenderer; import net.minecraft.client.renderer.ProjectionMatrixBuffer; import org.joml.Matrix4f; @@ -17,10 +18,11 @@ 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); + EdgeHighlights.captureProjection(matrix); return original.call(buffer, matrix); } @Inject(method="renderLevel", at=@At(value="INVOKE", target="Lnet/minecraft/client/renderer/GameRenderer;render3dHud(Lnet/minecraft/client/renderer/state/level/CameraRenderState;Lnet/minecraft/client/renderer/state/level/PlayerRenderState;Lnet/minecraft/client/renderer/state/OptionsRenderState;Z)V")) - private void sanctuary$shadows(CallbackInfo ci) { PixelShadows.render(); } + private void sanctuary$shadows(CallbackInfo ci) { PixelShadows.render(); EdgeHighlights.render(); } @Inject(method={"close", "setLevel"}, at=@At("HEAD")) - private void sanctuary$close(CallbackInfo ci) { PixelShadows.release(); } + private void sanctuary$close(CallbackInfo ci) { PixelShadows.release(); EdgeHighlights.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 28db74c..53ec0c9 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 @@ -2257,12 +2257,20 @@ "block.sanctuary.deepslate_sapphire_ore": "Deepslate Sapphire Ore", "sanctuary.discovery.granted": "Discoveries added for %s: %s blocks, %s items, %s species. Linked recipes updated; reopen catalogues.", "sanctuary.discovery.invalid": "Choose a discoverable block, item or species.", + "sanctuary.shaders.edge_highlights": "Edge highlights", + "sanctuary.shaders.edge_highlights_help": "Connected highlights along terrain contours. Flat surfaces connect, even across different blocks. Preserves textures. OFF by default; requires Vanilla Light.", + "sanctuary.shaders.edge_intensity": "Edge intensity", + "sanctuary.shaders.edge_intensity_help": "Highlight strength along edges. Independent of cast shadows.", + "sanctuary.shaders.edge_intensity_value": "Edge intensity: %s %%", "sanctuary.shaders.pixel_shadows": "Pixel shadows", - "sanctuary.shaders.pixel_shadows_help": "Sun shadows and subtle moon shadows on a pixel grid. Dappled foliage and softer shadows at noon. Disabled by default; requires Vanilla Light.", + "sanctuary.shaders.pixel_shadows_help": "Sun shadows and subtle moon shadows on a pixel grid. Dappled foliage and stable contours. Disabled by default; requires Vanilla Light.", "sanctuary.shaders.shadow_pixels": "Shadow precision", "sanctuary.shaders.shadow_pixels_value": "%s pixels/block", "sanctuary.shaders.shadow_pixels_help": "Grid precision: 8, 16 or 32 pixels per block. Higher values make shadow pixels finer. Requires Vanilla Light and pixel shadows.", "sanctuary.shaders.shadow_distance": "Shadow distance", "sanctuary.shaders.shadow_distance_value": "%s blocks", - "sanctuary.shaders.shadow_distance_help": "Shadow range: 16, 32 or 64 blocks. Higher distances cost more performance. Requires Vanilla Light and pixel shadows." + "sanctuary.shaders.shadow_distance_help": "Shadow range: 16, 32, 64, 128 or 256 blocks, within loaded terrain. Higher distances cost more performance. Requires Vanilla Light and pixel shadows.", + "sanctuary.shaders.edge_distance": "Highlight distance", + "sanctuary.shaders.edge_distance_value": "%s blocks", + "sanctuary.shaders.edge_distance_help": "Independent range: 16, 32, 64, 128 or 256 blocks. Limited to visible terrain and render distance. Distant contours become naturally finer." } 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 cbe3e45..dc80d41 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 @@ -2257,12 +2257,20 @@ "block.sanctuary.deepslate_sapphire_ore": "Minerai de saphir des abîmes", "sanctuary.discovery.granted": "Découvertes ajoutées pour %s : %s blocs, %s objets, %s espèces. Recettes liées actualisées ; rouvre les catalogues.", "sanctuary.discovery.invalid": "Choisis un bloc, un objet ou une espèce découvrable.", + "sanctuary.shaders.edge_highlights": "Reflets sur les arêtes", + "sanctuary.shaders.edge_highlights_help": "Reflets connectés sur les contours du relief. Les surfaces plates se raccordent, même entre blocs différents. Préserve les textures. Désactivé par défaut ; nécessite Vanilla Light.", + "sanctuary.shaders.edge_intensity": "Intensité des arêtes", + "sanctuary.shaders.edge_intensity_help": "Force du reflet sur les arêtes. Le réglage est indépendant des ombres portées.", + "sanctuary.shaders.edge_intensity_value": "Intensité des arêtes : %s %%", "sanctuary.shaders.pixel_shadows": "Ombres pixelisées", - "sanctuary.shaders.pixel_shadows_help": "Ombres du soleil et ombres légères de la lune, sur une grille de pixels. Feuillage irrégulier et ombres atténuées à midi. Désactivées par défaut ; nécessite Vanilla Light.", + "sanctuary.shaders.pixel_shadows_help": "Ombres du soleil et ombres légères de la lune, sur une grille de pixels. Feuillage irrégulier et contours stables. Désactivées par défaut ; nécessite Vanilla Light.", "sanctuary.shaders.shadow_pixels": "Précision des ombres", "sanctuary.shaders.shadow_pixels_value": "%s pixels/bloc", "sanctuary.shaders.shadow_pixels_help": "Précision de la grille : 8, 16 ou 32 pixels par bloc. Plus la valeur est élevée, plus les pixels des ombres sont fins. Nécessite Vanilla Light et les ombres pixelisées.", "sanctuary.shaders.shadow_distance": "Distance des ombres", "sanctuary.shaders.shadow_distance_value": "%s blocs", - "sanctuary.shaders.shadow_distance_help": "Portée des ombres : 16, 32 ou 64 blocs. Une distance plus élevée coûte davantage en performances. Nécessite Vanilla Light et les ombres pixelisées." + "sanctuary.shaders.shadow_distance_help": "Portée des ombres : 16, 32, 64, 128 ou 256 blocs, dans le terrain chargé. Une distance élevée coûte davantage en performances. Nécessite Vanilla Light et les ombres pixelisées.", + "sanctuary.shaders.edge_distance": "Portée des arêtes", + "sanctuary.shaders.edge_distance_value": "%s blocs", + "sanctuary.shaders.edge_distance_help": "Portée indépendante : 16, 32, 64, 128 ou 256 blocs. Limitée au terrain visible et à la distance de rendu. Les contours lointains s’affinent naturellement." } diff --git a/mods/sanctuary/src/main/resources/assets/sanctuary/shaders/core/edge_highlights.fsh b/mods/sanctuary/src/main/resources/assets/sanctuary/shaders/core/edge_highlights.fsh new file mode 100644 index 0000000..7fba04d --- /dev/null +++ b/mods/sanctuary/src/main/resources/assets/sanctuary/shaders/core/edge_highlights.fsh @@ -0,0 +1,71 @@ +#version 330 +#extension GL_ARB_separate_shader_objects : require +uniform sampler2D WorldDepth; +layout(std140) uniform EdgeHighlights { + mat4 InverseViewProjection; + mat4 ViewProjection; + vec4 CameraGrid; + vec4 StrengthClipFog; + vec4 EnvironmentFog; +}; +layout(location = 0) in vec2 texCoord; +layout(location = 0) out vec4 fragColor; + +vec3 positionAt(vec2 uv, float depth) { + float z = StrengthClipFog.y > 0.5 ? depth : depth * 2.0 - 1.0; + vec4 p = InverseViewProjection * vec4(uv * 2.0 - 1.0, z, 1.0); + return p.xyz / p.w; +} + +float exposed(vec3 probe, vec3 surface, vec3 normal) { + vec4 clip = ViewProjection * vec4(probe, 1.0); + if (clip.w <= 0.0) return 0.0; + vec2 uv = clip.xy / clip.w * 0.5 + 0.5; + if (any(lessThanEqual(uv, vec2(0.0))) || any(greaterThanEqual(uv, vec2(1.0)))) return 0.0; + // Reconstruct at the sampled depth texel's centre, not at a different subpixel position. + vec2 size = vec2(textureSize(WorldDepth, 0)); + uv = (floor(uv * size) + 0.5) / size; + float depth = texture(WorldDepth, uv).r; + if (depth <= 0.0) return 1.0; + float behind = dot(positionAt(uv, depth) - surface, normal); + // A coplanar neighbouring block contributes zero. Concave corners are not glowing seams. + return 1.0 - smoothstep(-0.045, -0.020, behind); +} + +float fogAmount(float distance, float start, float end) { + return clamp((distance - start) / max(end - start, 0.001), 0.0, 1.0); +} + +void main() { + float depth = texture(WorldDepth, texCoord).r; + vec3 relative = positionAt(texCoord, depth); + vec3 dx = dFdx(relative), dy = dFdy(relative); + vec3 normal = normalize(cross(dx, dy)); + if (dot(normal, -relative) < 0.0) normal = -normal; + vec3 an = abs(normal); + if (depth <= 0.0 || length(relative) >= EnvironmentFog.w || max(max(an.x, an.y), an.z) < 0.995) discard; + vec3 axis = an.x > an.y && an.x > an.z ? vec3(1,0,0) : an.y > an.z ? vec3(0,1,0) : vec3(0,0,1); + normal = axis * sign(dot(normal, axis)); + vec3 tangent = axis.x > 0.5 ? vec3(0,0,1) : vec3(1,0,0); + vec3 bitangent = abs(cross(axis, tangent)); + float width = 1.0 / CameraGrid.w; + vec3 p = relative + CameraGrid.xyz; + float plane = floor(dot(p, axis) * CameraGrid.w + 0.5) * width; + // Restrict to voxel planes; don't trace arbitrary moving models with a voxel-grid effect. + if (abs(dot(p, axis) - plane) > 0.003) discard; + vec3 q = (floor(p * CameraGrid.w + 0.0001) + 0.5) * width; + q += axis * (plane - dot(q, axis)); + q -= CameraGrid.xyz; + float probeWidth = max(width, max(length(dx), length(dy))); + float edge = max(max(exposed(q + tangent * probeWidth, q, normal), exposed(q - tangent * probeWidth, q, normal)), + max(exposed(q + bitangent * probeWidth, q, normal), exposed(q - bitangent * probeWidth, q, normal))); + // Connected surfaces share their outline, irrespective of the block texture. + // Integrate subpixel strips as coverage rather than deleting distant CTM outlines. + float footprint = max(length(dx), length(dy)); + float fade = min(1.0, width / max(footprint, width)); + fade *= 1.0 - smoothstep(EnvironmentFog.w * 0.75, EnvironmentFog.w, length(relative)); + float fog = max(fogAmount(length(relative), EnvironmentFog.x, EnvironmentFog.y), + fogAmount(max(length(relative.xz), abs(relative.y)), StrengthClipFog.z, StrengthClipFog.w)); + float highlight = edge * fade * StrengthClipFog.x * (1.0 - fog * EnvironmentFog.z); + fragColor = vec4(vec3(highlight), 0.0); +} diff --git a/packwiz/pack.toml b/packwiz/pack.toml index 7e0e868..e14d8d6 100644 --- a/packwiz/pack.toml +++ b/packwiz/pack.toml @@ -1,6 +1,6 @@ name = "Sanctuary" author = "KOKA99CAB" -version = "beta.128" +version = "beta.129" pack-format = "packwiz:1.1.0" [index]