From 98c48ce155cfedec87d4c3ec58f036a10efac0ca Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 22:01:31 +0000 Subject: [PATCH 01/24] Etna 0.8.1: solid water reflections, thin shoreline foam Reflections: fixed steps (24 + 6 halvings, no per-pixel noise, one fixed pixel per 2x2 block), a hit only where the ray runs into a surface rather than going behind something in front of it, misses written with their distance so the upsampler averages them (0.8.0 skipped them, so one lone hit grew into a speck), a 16-texel steady upsample, and the ray following a quarter of the swell's slope. The sky's reflection that is taken out keeps the full swell, as the water shader drew it. Foam: no foam over shallow floors and none on water flowing down a slope (new etnaWaterLevel flag from the unsnapped face normal); thinner, fainter line (0.2 blocks, 0.7) broken by fine bubbles, pale non-glowing colour. Version 0.8.1, changelog. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Xzo7zbTpyU7a3y3XQNbw1n --- CHANGELOG.md | 7 ++ gradle.properties | 2 +- .../assets/etna/shaders/core/terrain.fsh | 5 + .../etna/shaders/include/terrain_water.glsl | 60 +++++----- .../shaders/include/water_reflection.glsl | 19 ++- .../assets/etna/shaders/post/water_ssr.fsh | 109 ++++++++++-------- 6 files changed, 118 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5c3e9c..ec29705 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ Every release is for Minecraft 26.4-snapshot-3 with Fabric. Versions: the middle number goes up for new features, the last one for fixes. Jars are on the [Releases](https://github.com/Atomsk-debug/Etna/releases) page. +## 0.8.1 (unreleased) + +Fixes for 0.8.0's water. + +* **Reflections keep their shapes.** The shore, trees and hills mirrored in the water came out as scattered dark dots. Each reflected ray now takes the same fixed steps every frame and counts as a hit only where it really runs into something, rays that find nothing are averaged in instead of dropped, and the mirror image follows a calmer copy of the waves, so it wobbles as a whole instead of breaking up. +* **Foam, redone.** Only a thin, faint, bubbly line where still water meets the shore, in a pale colour that no longer glows. Shallow water stays clear (0.8.0 foamed every floor just under the surface into a milky haze), and water flowing down a slope has no foam. + ## 0.8.0 (2026-10-10), "Enchanted" Magic, and closing the gap with the most popular shader packs. Every new effect has its own setting, none of them is part of the Look, and with Etna off none of them is left. diff --git a/gradle.properties b/gradle.properties index b676395..9b2d687 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,7 +12,7 @@ loom_version=1.18-SNAPSHOT # Mod Properties # Bump on every build handed out for testing: patch for fixes, minor for new features. The jar and the # version shown in the game's mod list both become +mc. -mod_version=0.8.0 +mod_version=0.8.1 group=it.cosimo.etna # Dependencies diff --git a/src/main/resources/assets/etna/shaders/core/terrain.fsh b/src/main/resources/assets/etna/shaders/core/terrain.fsh index a7562dc..ba7fe6c 100644 --- a/src/main/resources/assets/etna/shaders/core/terrain.fsh +++ b/src/main/resources/assets/etna/shaders/core/terrain.fsh @@ -134,6 +134,10 @@ vec3 etnaWaterSky(vec3 dir) { float etnaPixelFootprint = 0.0; // How many of the atlas's texels one screen pixel spans, from texCoord0's derivatives, set with it. float etnaTexelFootprint = 0.0; +// Whether a water face lies flat, within about 4 degrees, from etnaViewPos's derivatives before +// geometricNormal snaps them onto an axis: still water's top does, water flowing down a slope (a block's +// corners a level of water apart, 6 degrees or more) doesn't. Set in main for water. +bool etnaWaterLevel = false; #ifdef IMPROVED_FOG vec3 sampleBackgroundColor(ivec2 pixelCoords) { @@ -825,6 +829,7 @@ void main() { // falling column, tilted towards x or z, still get an average. vec3 faceNormal = abs(cross(posDx, posDy)); vec3 inBlock = fract(etnaViewPos - CameraOffset); + etnaWaterLevel = faceNormal.y * faceNormal.y > 0.995 * dot(faceNormal, faceNormal); if (faceNormal.y >= max(faceNormal.x, faceNormal.z)) { if (faceNormal.y > 1e-3 * length(posDx.xz) * length(posDy.xz)) { color = etnaWaterTexture(uvDx, uvDy, posDx, posDy); diff --git a/src/main/resources/assets/etna/shaders/include/terrain_water.glsl b/src/main/resources/assets/etna/shaders/include/terrain_water.glsl index e9910e7..61b76d4 100644 --- a/src/main/resources/assets/etna/shaders/include/terrain_water.glsl +++ b/src/main/resources/assets/etna/shaders/include/terrain_water.glsl @@ -558,31 +558,35 @@ float waterThickness(vec3 surfacePos, out float known) { } // ---- Shoreline foam ----------------------------------------------------------------------------------- -// A thin, soft line of foam where the water meets the shore, and a little over a floor just under the -// surface. Minecraft's shores run along the edges of blocks, so the line is measured from those edges: -// the shadow map, which holds opaque terrain only, says which of the eight blocks around this one are -// land, from one read at each block's middle on the water's plane, and the line follows the nearest land -// block's edge exactly, however far away and whatever the map's resolution. Smooth noise fixed in the -// world wobbles its edge, breaks it into patches and drifts slowly round, and the line breathes a little -// as if the water lapped at the shore. It is lit like a white top face in the same place (sun, shade, sky, -// torches). The map can't tell a block of land from water lying in that block's shade, so where the light -// comes from behind a neighbouring block, only land standing barely above the water (a beach, where shade -// can't reach that far) counts: banks get their foam on the side the light comes from. Without the map -// (shadows off, dusk, rain) there is none. +// A thin, faint, broken line of foam where still water meets the shore. Minecraft's shores run along the +// edges of blocks, so the line is measured from those edges: the shadow map, which holds opaque terrain +// only, says which of the eight blocks around this one are land, from one read at each block's middle on +// the water's plane, and the line follows the nearest land block's edge exactly, however far away and +// whatever the map's resolution. Smooth noise fixed in the world wobbles its edge, breaks it into patches +// and drifts slowly round, fine bubbles open it up close by, and the line breathes a little as if the +// water lapped at the shore. It is lit like a pale top face in the same place (sun, shade, sky, torches), +// never brighter than the sand beside it. The map can't tell a block of land from water lying in that +// block's shade, so where the light comes from behind a neighbouring block, only land standing barely +// above the water (a beach, where shade can't reach that far) counts: banks get their foam on the side the +// light comes from. Without the map (shadows off, dusk, rain) there is none. 0.8.0 also foamed every floor +// lying just under the surface, which turned thin sheets of flowing water down a bank into blotches and +// shallow water into a milky haze: shallow water now stays clear, and water flowing down a slope has no +// foam. // Width of the line, in blocks, before the noise wobbles it. -const float FOAM_WIDTH = 0.3; +const float FOAM_WIDTH = 0.2; // How much of the water the line covers where it is thickest, at the setting's full strength. -const float FOAM_OPACITY = 0.85; +const float FOAM_OPACITY = 0.7; // Land counts only up to this height above the water's plane (along the light, as the map measures it): // higher, it is more likely the shade of a tree or a cliff top than a shore. const float FOAM_BANK = 1.3; // Land behind the block, as the light sees it, counts only up to this height: a beach's top. const float FOAM_BEACH = 0.2; -// Water shallower than this over its floor, in blocks, foams too. -const float FOAM_SHALLOW = 0.3; -// The foam's own colour: white with a breath of blue. -const vec3 FOAM_ALBEDO = vec3(0.80, 0.85, 0.88); +// The foam's own colour: a pale grey-white with a breath of blue, so in full sun it reads as foam and +// doesn't glow. +const vec3 FOAM_ALBEDO = vec3(0.66, 0.70, 0.72); +// Bubbles per block, along each axis. +const float FOAM_BUBBLES = 6.0; // Smooth value noise, 0 .. 1, on a lattice of one block: fixed in the world, no detail finer than a block. float foamNoise(vec2 p) { @@ -598,8 +602,8 @@ float foamNoise(vec2 p) { } // How strongly foam covers the water surface at world xz p and time t (seconds), 0 .. 1, before the -// setting. vertical: blocks of water under the surface, depthKnown how far that can be trusted. -float shoreFoam(vec2 p, float t, float vertical, float depthKnown) { +// setting. +float shoreFoam(vec2 p, float t) { if (EtnaWaterSsr.x <= 0.0 || EtnaLook2.w < 0.5) { return 0.0; } @@ -646,15 +650,18 @@ float shoreFoam(vec2 p, float t, float vertical, float depthKnown) { float turn2 = t * (6.2831853 / 75.0); float wobble = foamNoise(p * 1.6 + 0.6 * vec2(cos(turn), sin(turn))) - 0.5; float patches = smoothstep(0.25, 0.7, foamNoise(p * 3.1 + vec2(17.3, 5.1) + 0.4 * vec2(sin(turn2), cos(turn2)))); + // Bubbles, merging into their average where a pixel spans several of them, so far away they never + // crawl. + float bubbles = smoothstep(0.3, 0.65, foamNoise(p * FOAM_BUBBLES + vec2(3.7, 11.9) + 0.5 * vec2(cos(turn2), sin(turn)))); + bubbles = mix(bubbles, 0.5, smoothstep(0.25, 1.0, etnaPixelFootprint * FOAM_BUBBLES)); // The water lapping at the shore: the line breathes every 8 seconds, at its own moment along the shore. float lap = 0.85 + 0.15 * etnaSin(t * (6.2831853 / 8.0) + 6.0 * wobble); // Never thinner than a pixel and a half: wider and fainter instead, so it never breaks up into crawling dots. float width = FOAM_WIDTH * lap; float shown = max(width, 1.5 * etnaPixelFootprint); - float line = (1.0 - smoothstep(0.0, shown, nearest + 0.12 * wobble)) * (width / shown); - float shallow = (1.0 - smoothstep(0.05, FOAM_SHALLOW, vertical)) * depthKnown * 0.6; + float line = (1.0 - smoothstep(0.0, shown, nearest + 0.08 * wobble)) * (width / shown); float edge = 1.0 - smoothstep(0.85, 1.0, here.w); - return max(line, shallow) * (0.45 + 0.55 * patches) * edge; + return line * (0.2 + 0.8 * patches) * (0.35 + 0.65 * bubbles) * edge; } // The light on foam at this point: what a white top face here would get (shadeTerrain's light for it). @@ -670,13 +677,14 @@ vec3 foamLight() { // the shore. vanillaAlpha: the water texture's alpha; lit: the surface as shadeTerrain lit it, whose // brightness (shade of trees and cliffs, time of day) the water's body takes, with the hue of the water's // own tint. fresnel and reflection: the sky's reflection, glint: the sun's, with the glitter. p and t: -// where (world xz) and when the foam is worked out. The blend then gives +// where (world xz) and when the foam is worked out; level: whether the surface lies flat (no foam on water +// flowing down a slope). The blend then gives // behind * (1 - a)(1 - F) + body * (1 - T)(1 - F) + reflection * F + glint, with T what the water lets // through on the way to the bottom and back up to the eye, and a its average. Where the depth is unknown // the body is the lit texture's colour and a its alpha: the look water had before it had depth, with what // it mirrors taken out of what it lets through, so the sky's reflection is always exactly reflection * F // (the water reflections pass, post/water_ssr.fsh, takes that out again where it finds the world instead). -vec4 waterFromAbove(float vanillaAlpha, vec3 lit, vec3 viewDir, vec3 reflection, float fresnel, vec3 glint, vec2 p, float t) { +vec4 waterFromAbove(float vanillaAlpha, vec3 lit, vec3 viewDir, vec3 reflection, float fresnel, vec3 glint, vec2 p, float t, bool level) { float plainAlpha = 1.0 - (1.0 - vanillaAlpha) * (1.0 - fresnel); vec4 surface = etnaFitAlpha((lit * (vanillaAlpha * (1.0 - fresnel)) + reflection * fresnel + glint) / max(plainAlpha, 0.05), plainAlpha); float known; @@ -696,7 +704,7 @@ vec4 waterFromAbove(float vanillaAlpha, vec3 lit, vec3 viewDir, vec3 reflection, covered = mix(covered, deep.rgb * deep.a, known); outAlpha = mix(outAlpha, deep.a, known); } - float foam = shoreFoam(p, t, vertical, known) * EtnaWaterSsr.x * FOAM_OPACITY; + float foam = level ? shoreFoam(p, t) * EtnaWaterSsr.x * FOAM_OPACITY : 0.0; if (foam > 0.003) { // A layer over the water, which covers what the water shows by its own share. covered = mix(covered, FOAM_ALBEDO * foamLight(), foam); @@ -923,7 +931,7 @@ vec4 shadeWater(vec4 color, vec3 normal) { vec3 glint = sunColor() * sun; #if defined(ETNA_SHADOWS) && defined(ETNA_TRANSLUCENT_LAYER) if (normal.y > 0.8 && cameraInAir() > 0.5) { - return waterFromAbove(color.a, color.rgb, viewDir, skyReflection, fresnel * ETNA_WATER_REFLECTIONS, glint, p, t); + return waterFromAbove(color.a, color.rgb, viewDir, skyReflection, fresnel * ETNA_WATER_REFLECTIONS, glint, p, t, etnaWaterLevel); } #endif return vec4(mix(color.rgb, skyReflection, fresnel * ETNA_WATER_REFLECTIONS) + glint, color.a); diff --git a/src/main/resources/assets/etna/shaders/include/water_reflection.glsl b/src/main/resources/assets/etna/shaders/include/water_reflection.glsl index 0d650f2..1d9db20 100644 --- a/src/main/resources/assets/etna/shaders/include/water_reflection.glsl +++ b/src/main/resources/assets/etna/shaders/include/water_reflection.glsl @@ -7,10 +7,10 @@ // here that change is added at full resolution. Each of its texels holds the change for one pixel and // that pixel's distance, so only texels at about this pixel's own distance are blended (joint bilateral // upsampling, as world_light.fsh's litShare does for the light shafts): a lake's reflection never bleeds -// onto the shore in front of it. With anti-aliasing the four nearest texels are enough (the pass traces a -// different pixel of each 2x2 block every frame and the anti-aliasing averages them), without it the -// sixteen nearest under a tent twice as wide. Only pixels the water marked (the picture's alpha, see -// WATER_MASK_LIMIT) take it. +// onto the shore in front of it. The sixteen nearest texels count, under a tent two texels wide, the same +// every frame. Water texels whose ray met nothing hold no change and count, so a lone hit is averaged +// with the misses around it instead of growing into a speck. Only pixels the water marked (the picture's +// alpha, see WATER_MASK_LIMIT) take it. #include @@ -22,11 +22,10 @@ vec3 etnaWaterReflection(vec3 color, ivec2 pixel, float dist) { if (WaterSsr.x <= 0.0 || Water.x > 0.5 || dist <= 0.0 || texelFetch(InSampler, pixel, 0).a >= WATER_MASK_LIMIT) { return color; } - int radius = Frame.w > 0.5 ? 1 : 2; - float spread = float(radius); - // Texel c holds pixel 2c + sub: with anti-aliasing sub cycles over the 2x2 block, so on average the - // block's centre; without, its first pixel. - vec2 p = vec2(pixel) * 0.5 - (Frame.w > 0.5 ? 0.25 : 0.0); + const int radius = 2; + const float spread = 2.0; + // Texel c holds pixel 2c. + vec2 p = vec2(pixel) * 0.5; vec2 corner = floor(p); vec2 f = p - corner; ivec2 base = ivec2(corner); @@ -40,7 +39,7 @@ vec3 etnaWaterReflection(vec3 color, ivec2 pixel, float dist) { for (int x = 1 - radius; x <= radius; x++) { float wx = max(1.0 - abs(float(x) - f.x) / spread, 0.0); vec4 texel = texelFetch(WaterSsrSampler, clamp(base + ivec2(x, y), ivec2(0), last), 0); - // Texels that aren't water (distance 0) hold no change and don't count. + // Texels that aren't water seen from above (distance 0) don't count. if (texel.a <= 0.0) { continue; } diff --git a/src/main/resources/assets/etna/shaders/post/water_ssr.fsh b/src/main/resources/assets/etna/shaders/post/water_ssr.fsh index 88cb1e2..9e5aac0 100644 --- a/src/main/resources/assets/etna/shaders/post/water_ssr.fsh +++ b/src/main/resources/assets/etna/shaders/post/water_ssr.fsh @@ -2,30 +2,38 @@ #extension GL_ARB_separate_shader_objects : require // Etna world pass, at half resolution: the world reflected in the water (screen-space reflections). -// One texel stands for a 2x2 block of screen pixels; while anti-aliasing runs it takes a different one of -// the four every frame, as the light shafts pass does (shafts.fsh), so over four frames every pixel's own -// reflection has been traced. +// One texel stands for a 2x2 block of screen pixels, always its first, with or without anti-aliasing. +// 0.8.0 traced a different pixel of the block each frame along steps shifted by noise, for the +// anti-aliasing to average, but where only some rays met a thin edge (leaves, a fence, a block's corner) +// the anti-aliasing kept them as specks, and the reflections read as dots. The same rays every frame give +// a steady image that only moves with the camera and the waves. // // Without improved transparency the water is drawn into Minecraft's picture after everything opaque, and // it writes the depth buffer: after the level the depth there is the water's surface, exactly where a // reflected ray starts. Etna's water also marks its pixels: its pipelines (EtnaShadows.receiverFor) blend // alpha with factors 0 and 1 - alpha, so the picture's alpha, 1 after the opaque pass, ends up below 1 // only where water (or glass, or ice) was drawn. Each water pixel seen from above has its ray reflected off -// the same swell the water shader drew (water_waves.glsl) and marched across the screen, 12 steps and 3 -// halvings with anti-aliasing (the steps shift every frame and the anti-aliasing averages them), 20 and 4 -// without (fixed steps, no noise to flicker). Where the ray meets something the depth buffer holds (the -// shore, trees, hills, vanilla's clouds), the picture there is what the water mirrors in place of the -// sky the water shader drew; where it leaves the screen, meets nothing, or passes behind something it -// can't see round, the sky stays. Near the screen's edges the world's reflection fades into the sky's. +// a calmer copy of the swell the water shader drew (water_waves.glsl, TRACE_WAVES of its slope: the +// mirrored shore wobbles, it doesn't break up) and marched across the screen in STEPS fixed steps, the +// same for every pixel and every frame, so neighbouring pixels find the same things and the reflection +// keeps its shapes. Where the ray first passes behind what the depth buffer holds, the step is halved +// HALVINGS times down to the crossing; it is a hit when the surface there lies no nearer than the ray was +// just before (a hill, the shore, a tree it really ran into), not when the ray merely went behind +// something standing in front of it. Where it meets something (the shore, trees, hills, vanilla's +// clouds), the picture there is what the water mirrors in place of the sky the water shader drew; where it +// leaves the screen, meets nothing, or passes behind something it can't see round, the sky stays. Near +// the screen's edges the world's reflection fades into the sky's. // // The water shader drew the sky's reflection as reflection * F (terrain_water.glsl, waterFromAbove), // before the distance haze and vanilla's render-distance fog. This pass works that same amount out again // (the same waves, the same sky, the same Fresnel and water reflections setting) and writes how much the // picture changes when the world's reflection takes its place: rgb, the change to add (it can be // negative), a: the distance of the water surface in blocks, which the world pass uses to blend only -// texels at its pixel's own distance (water_reflection.glsl). 0 everywhere the pixel isn't water seen from -// above, with the camera in water, lava or powder snow, and while the reflections are off (WaterSsr.x 0: -// the setting at 0, improved transparency on). +// texels at its pixel's own distance (water_reflection.glsl). A water pixel whose ray meets nothing still +// writes its distance with no change, so the upsampling averages it with its neighbours' hits rather than +// spreading one lone hit over them. 0 everywhere the pixel isn't water seen from above, with the camera +// in water, lava or powder snow, and while the reflections are off (WaterSsr.x 0: the setting at 0, +// improved transparency on). // // How much sky the water mirrored also depends on how open its surface is to the sky (none in a cave), // which the picture doesn't say. It is taken as fully open unless the camera is underground, and never @@ -51,17 +59,17 @@ layout(std140) uniform SamplerInfo { layout(location = 0) out vec4 fragColor; -// Which pixel of the 2x2 block a texel stands for, frame by frame (shafts.fsh's order). -const ivec2 SUBPIXEL[4] = ivec2[4](ivec2(0, 0), ivec2(1, 1), ivec2(1, 0), ivec2(0, 1)); - -// Steps along the ray and halvings of the step that found something, with and without anti-aliasing. -const int STEPS_TAA = 12; -const int REFINE_TAA = 3; -const int STEPS_PLAIN = 20; -const int REFINE_PLAIN = 4; -// Step i lies ((i + shift) / steps)^STEP_CURVE of the way along the ray: closer together near the water, -// where the shore's reflection is and where the depth changes fastest. -const float STEP_CURVE = 1.6; +// Steps along the ray, and halvings of the step in which the ray first passes behind something. The same +// with and without anti-aliasing: the steps don't move from frame to frame, so there is nothing for the +// anti-aliasing to average and nothing to flicker. +const int STEPS = 24; +const int HALVINGS = 6; +// Step i lies (i / STEPS)^STEP_CURVE of the way along the ray: closer together near the water, where the +// shore's reflection is and where the depth changes fastest. +const float STEP_CURVE = 1.4; +// Share of the swell's slope the reflected rays follow. At full strength neighbouring rays scatter over +// the far shore and its reflection breaks into specks; at this much it wobbles as a whole. +const float TRACE_WAVES = 0.25; // How far the ray runs before the screen's edges cut it off, in blocks. const float RAY_LENGTH = 512.0; // The ray starts this far above the surface, in blocks, so it can't find the water itself. @@ -73,11 +81,11 @@ const float RAY_LIFT = 0.02; const float RAY_ABOVE = 0.05; // The nearest a ray coming back towards the camera may get, in blocks of view depth. const float RAY_NEAR = 0.1; -// A surface the ray has passed behind counts as hit only within this depth of it, in blocks, or this -// share of its distance (far away a step of the depth buffer spans more): behind more, the ray has -// gone round the back of something it can't see. -const float THICKNESS = 1.0; -const float THICKNESS_SHARE = 0.02; +// The ray has run into a surface, rather than gone behind something in front of it, when that surface +// lies no more than this much nearer than the ray was just before the crossing: this many blocks, or this +// share of its distance (far away one pixel of the depth buffer spans more). +const float THICKNESS = 1.5; +const float THICKNESS_SHARE = 0.05; // Share of the screen, at each edge, over which the world's reflection fades into the sky's. const float EDGE_FADE = 0.07; // Only faces turned within about 25 degrees of straight up reflect: the water's top (flowing water tilts @@ -127,19 +135,23 @@ float viewDepth(vec3 screen) { return 1.0 / dot(vec4(InvViewProj[0].w, InvViewProj[1].w, InvViewProj[2].w, InvViewProj[3].w), vec4(screen, 1.0)); } -// Nothing to change here. +// Nothing to change here: not water seen from above. void nothing() { fragColor = vec4(0.0); } +// Water whose reflection stays the sky the water shader drew, dist blocks away. +void unchanged(float dist) { + fragColor = vec4(0.0, 0.0, 0.0, dist); +} + void main() { if (WaterSsr.x <= 0.0 || Water.x > 0.5) { nothing(); return; } ivec2 cell = ivec2(gl_FragCoord.xy); - ivec2 sub = Frame.w > 0.5 ? SUBPIXEL[int(Frame.x) & 3] : ivec2(0); - ivec2 pixel = min(cell * 2 + sub, ivec2(DepthSize) - 1); + ivec2 pixel = min(cell * 2, ivec2(DepthSize) - 1); vec4 picture = texelFetch(InSampler, pixel, 0); float depth = depthAtPixel(pixel); if (picture.a >= WATER_MASK_LIMIT || etnaIsSky(depth)) { @@ -185,7 +197,10 @@ void main() { vec3 n = normalize(vec3(-slope.x, 1.0, -slope.y)); float dist = length(position); vec3 toEye = -position / dist; + // The sky's reflection the water drew, and how strong, came off the full swell; the ray follows a + // calmer one. vec3 dir = reflect(-toEye, n); + vec3 ray = reflect(-toEye, normalize(vec3(-slope.x * TRACE_WAVES, 1.0, -slope.y * TRACE_WAVES))); float mirror = waterFresnel(dot(toEye, n)) * WaterSsr.x; // The haze and the render-distance fog the water shader's colour went through (aerialHaze in // terrain_dark.glsl, calculateFinalColor in terrain.fsh), for the light the reflection adds or takes away. @@ -195,9 +210,9 @@ void main() { float span = clamp(far / 10.0, 4.0, 64.0); float fogged = clamp((max(length(position.xz), abs(position.y)) - (far - span)) / span, 0.0, 1.0); float keep = (1.0 - hazed) * (1.0 - fogged); - if (dir.y < 0.02 || mirror * keep < 0.004) { + if (ray.y < 0.02 || mirror * keep < 0.004) { // A ray that skims the water, or a reflection too faint to change the picture. - nothing(); + unchanged(dist); return; } @@ -205,13 +220,13 @@ void main() { // It ends RAY_LENGTH blocks out, sooner if it comes back towards the camera, and at the screen's edges. vec3 start = position + vec3(0.0, RAY_LIFT, 0.0); vec4 hStart = ViewProj * vec4(start, 1.0); - vec4 hDir = ViewProj * vec4(dir, 0.0); + vec4 hDir = ViewProj * vec4(ray, 0.0); float len = RAY_LENGTH; if (hDir.w < 0.0) { len = min(len, (hStart.w - RAY_NEAR) / -hDir.w); } vec3 s0 = hStart.xyz / hStart.w; - vec3 s1 = etnaScreen(ViewProj, start + dir * len); + vec3 s1 = etnaScreen(ViewProj, start + ray * len); vec3 span3 = s1 - s0; float exit = 1.0; for (int i = 0; i < 2; i++) { @@ -225,26 +240,25 @@ void main() { exit = min(exit, -s0.z / span3.z); } if (exit <= 0.0) { - nothing(); + unchanged(dist); return; } - bool temporal = Frame.w > 0.5; - int steps = temporal ? STEPS_TAA : STEPS_PLAIN; - int halvings = temporal ? REFINE_TAA : REFINE_PLAIN; - float shift = temporal ? etnaNoise(gl_FragCoord.xy) : 0.5; float before = 0.0; + // Whether the ray lay in front of what the depth buffer holds at the last step: it can only run into + // something where it goes from in front to behind. + bool ahead = true; float found = -1.0; - for (int i = 0; i < steps; i++) { - float t = exit * pow((float(i) + shift) / float(steps), STEP_CURVE); + for (int i = 1; i <= STEPS; i++) { + float t = exit * pow(float(i) / float(STEPS), STEP_CURVE); vec3 s = s0 + span3 * t; - float there = depthAt(s.xy); // Reversed depth: the ray lies behind what the depth buffer holds where its depth is smaller. - if (s.z < there) { + bool behind = s.z < depthAt(s.xy); + if (behind && ahead) { // Halve the step down to the point where the ray passes behind that surface. float lo = before; float hi = t; - for (int k = 0; k < halvings; k++) { + for (int k = 0; k < HALVINGS; k++) { float mid = 0.5 * (lo + hi); vec3 m = s0 + span3 * mid; if (m.z < depthAt(m.xy)) { @@ -256,16 +270,17 @@ void main() { vec3 h = s0 + span3 * hi; vec3 there3 = vec3(h.xy, depthAt(h.xy)); float surface = viewDepth(there3); - if (viewDepth(h) - surface < max(THICKNESS, THICKNESS_SHARE * surface) + if (surface > viewDepth(s0 + span3 * lo) - max(THICKNESS, THICKNESS_SHARE * surface) && etnaPosition(there3.xy, there3.z).y > position.y + RAY_ABOVE) { found = hi; break; } } + ahead = !behind; before = t; } if (found < 0.0) { - nothing(); + unchanged(dist); return; } vec3 hit = s0 + span3 * found; From 61d40dcf70e3ca4f242400d48aa44d56ba1de3cf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 22:24:34 +0000 Subject: [PATCH 02/24] Etna 0.8.1: shadows that soften with distance, fainter low-sun shadows Contact hardening (the PCSS idea): 8 gathers look for casters round the point; the blur radius is the softness plus 0.025 x (softness - 0.25) of their average distance along the light, capped at 1.2 blocks / 32 texels. The near part keeps the exact area-weighted box, the wide part a 12-point bilinear-PCF Vogel disk, blended between. No casters found: lit at once. At the lowest softness all of it is skipped. Low sun: shadows fade up to 35% lighter below ~20 degrees. From a review of the shadow code: receivers beyond the shadow casters' chunk box (16 * ceil((distance + 14) / 16) blocks along world x/z) fade to lit instead of ending at a hard line; points beyond the map's far depth plane count as lit (terrain filter, unfiltered test, leaves, shafts) instead of shadowed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Xzo7zbTpyU7a3y3XQNbw1n --- CHANGELOG.md | 6 +- .../etna/shaders/include/terrain_shadow.glsl | 148 +++++++++++++++++- .../assets/etna/shaders/post/shafts.fsh | 4 + 3 files changed, 152 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec29705..5fd78b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,11 @@ Every release is for Minecraft 26.4-snapshot-3 with Fabric. Versions: the middle ## 0.8.1 (unreleased) -Fixes for 0.8.0's water. +Fixes for 0.8.0's water, and shadows that soften with distance. + +* **Shadows soften with distance, as real ones do.** A shadow is sharp where it meets what casts it and blurs the further it reaches: a tree's shadow is crisp at the trunk and a soft smudge at its far end, and long evening shadows lose their detail and fade towards the tip. Before, every shadow edge had the same blur. Shadow Softness now also sets how quickly they soften; at its lowest, edges stay hard everywhere. +* **Long low-sun shadows are fainter.** Below about 20 degrees the sky fills shadows in a little, up to a third lighter at the horizon. +* Far shadows fade out instead of stopping at a straight line about 180 blocks away when looking towards or away from a low sun, and ground more than 320 blocks below the camera along the light (far away under a low sun, or seen from very high up) no longer turns dark. * **Reflections keep their shapes.** The shore, trees and hills mirrored in the water came out as scattered dark dots. Each reflected ray now takes the same fixed steps every frame and counts as a hit only where it really runs into something, rays that find nothing are averaged in instead of dropped, and the mirror image follows a calmer copy of the waves, so it wobbles as a whole instead of breaking up. * **Foam, redone.** Only a thin, faint, bubbly line where still water meets the shore, in a pale colour that no longer glows. Shallow water stays clear (0.8.0 foamed every floor just under the surface into a milky haze), and water flowing down a slope has no foam. diff --git a/src/main/resources/assets/etna/shaders/include/terrain_shadow.glsl b/src/main/resources/assets/etna/shaders/include/terrain_shadow.glsl index 180a9c8..6ea16e3 100644 --- a/src/main/resources/assets/etna/shaders/include/terrain_shadow.glsl +++ b/src/main/resources/assets/etna/shaders/include/terrain_shadow.glsl @@ -58,6 +58,90 @@ vec2 etnaFilterGather(sampler2D map, int x, int y, vec2 lo, vec2 hi, vec2 center } #endif +// ---- Contact hardening ------------------------------------------------------------------------------ +// Real shadows are sharp where they meet what casts them and blur the further they reach from it: the sun +// is a disc, not a point, so the edge's blur grows with the distance between the caster and the ground. +// A tree's shadow is crisp at its trunk and a soft smudge at its far end, and a long evening shadow +// loses its detail and fades out towards its tip. Before 0.8.1 every edge had the same blur, at the +// trunk and 25 blocks away. The idea is percentage-closer soft shadows (Randima Fernando, "Percentage- +// Closer Soft Shadows", NVIDIA, 2005), written here for Etna's map: a few reads round the point find +// how far in front of it, towards the light, the casters lie on average; the blur's radius is the shadow +// softness setting plus SHADOW_SPREAD of that distance, times how far the setting lies above its lowest +// (0.25: hard shadow edges stay hard, and skip all of this); the near part of a shadow keeps the exact area-weighted box filter below, the wide part reads +// SHADOW_DISK points spread over a disc of that radius, and the two blend where the blur widens. + +// Blocks of blur radius per block between the caster and the ground, per unit of shadow softness above +// its lowest: at the default 1.3 about five times the real sun's, which looks right at Minecraft's scale. +const float SHADOW_SPREAD = 0.025; +// The widest blur, in blocks and in texels of the map: the casters are looked for this far round the +// point. +const float SHADOW_SPREAD_BLOCKS = 1.2; +const float SHADOW_SPREAD_TEXELS = 32.0; +// Gathers (four texels each) that look for casters, the first at the point itself, and gathers that +// average a wide blur. +const int SHADOW_SEARCH = 8; +const int SHADOW_DISK = 12; +const float SHADOW_GOLDEN_ANGLE = 2.39996323; + +// The 2x2 texels round corner `corner` of the map (in texels; texel i spans i .. i + 1), in +// textureGather's order: (-1, 0), (0, 0), (0, -1) and (-1, -1) from the corner. +vec4 etnaShadowQuad(sampler2D map, vec2 corner) { + #ifdef ETNA_SHADOW_GATHER + return textureGather(map, corner * max(EtnaShadowInfo.x, 1e-6)); + #else + ivec2 c = ivec2(corner); + ivec2 last = textureSize(map, 0) - 1; + return vec4(texelFetch(map, clamp(c + ivec2(-1, 0), ivec2(0), last), 0).r, + texelFetch(map, clamp(c, ivec2(0), last), 0).r, + texelFetch(map, clamp(c + ivec2(0, -1), ivec2(0), last), 0).r, + texelFetch(map, clamp(c - 1, ivec2(0), last), 0).r); + #endif +} + +// The receiver plane's depth at the four texels of etnaShadowQuad(corner): depth at `center`, changing by +// `slope` per texel. +vec4 etnaShadowPlane(vec2 corner, vec2 center, float depth, vec2 slope) { + float here = depth + dot(slope, corner - center); + return here + vec4(dot(slope, vec2(-0.5, 0.5)), dot(slope, vec2(0.5, 0.5)), dot(slope, vec2(0.5, -0.5)), dot(slope, vec2(-0.5, -0.5))); +} + +// What casts a shadow here, looked for in SHADOW_SEARCH gathers spread over a disc `radius` texels wide +// round `center` (a Vogel spiral turned by `turn`, the first at the centre): x, how many of the texels +// read lie in front of the receiver plane, towards the light; y, how far in front on average, in depth +// units. +vec2 etnaShadowBlockers(sampler2D map, vec2 center, float radius, float depth, vec2 slope, float bias, float turn) { + float found = 0.0; + float sum = 0.0; + for (int i = 0; i < SHADOW_SEARCH; i++) { + float r = sqrt(float(i) / float(SHADOW_SEARCH)) * radius; + float a = float(i) * SHADOW_GOLDEN_ANGLE + turn; + vec2 corner = floor(center + r * vec2(cos(a), sin(a)) + 0.5); + // Reversed depth: bigger is closer to the light. + vec4 front = etnaShadowQuad(map, corner) - etnaShadowPlane(corner, center, depth, slope); + vec4 hit = step(vec4(bias), front); + found += dot(hit, vec4(1.0)); + sum += dot(hit, front); + } + return vec2(found, sum / max(found, 1.0)); +} + +// The lit share over a disc `radius` texels wide round `center`: SHADOW_DISK points on a Vogel spiral +// turned by `turn`, each the bilinear blend of the four texels round it (so the result slides smoothly +// as the sun moves instead of stepping a texel at a time). +float etnaShadowDisk(sampler2D map, vec2 center, float radius, float depth, vec2 slope, float bias, float turn) { + float lit = 0.0; + for (int i = 0; i < SHADOW_DISK; i++) { + float r = sqrt((float(i) + 0.5) / float(SHADOW_DISK)) * radius; + float a = float(i) * SHADOW_GOLDEN_ANGLE + turn; + vec2 q = center + r * vec2(cos(a), sin(a)); + vec2 corner = floor(q - 0.5) + 1.0; + vec2 f = q - (corner - 0.5); + vec4 open = step(etnaShadowQuad(map, corner), etnaShadowPlane(corner, center, depth, slope) + bias); + lit += dot(open, vec4((1.0 - f.x) * f.y, f.x * f.y, f.x * (1.0 - f.y), (1.0 - f.x) * (1.0 - f.y))); + } + return lit / float(SHADOW_DISK); +} + // shadowVisibility on one of the two maps (`map`, drawn through `shadowMat`). float etnaShadowFilter(sampler2D map, mat4 shadowMat, vec3 normal, bool thin) { vec3 undistorted = (shadowMat * vec4(etnaViewPos, 1.0)).xyz; @@ -80,6 +164,12 @@ float etnaShadowFilter(sampler2D map, mat4 shadowMat, vec3 normal, bool thin) { #else float depth = ndc.z * 0.5 + 0.5; #endif + // Beyond the far end of the map's depth range (more than DEPTH_RANGE blocks below the camera along + // the light: far away under a low sun, or the ground seen from high up) the cleared map holds nothing, + // and nothing there can be told to be in shadow. + if (depth <= 0.0) { + return 1.0; + } float size = 1.0 / max(EtnaShadowInfo.x, 1e-6); // Receiver plane: how the surface's own depth in the map changes per texel across the filter. // A surface the sun grazes climbs towards the light within a few texels; compared against one @@ -115,6 +205,29 @@ float etnaShadowFilter(sampler2D map, mat4 shadowMat, vec3 normal, bool thin) { // anti-aliasing pass, which keeps only the history that fits this frame's neighbouring pixels, // dropped the averaged edge and let them flicker. vec2 center = uv * size; + float fade = smoothstep(0.85, 1.0, edge); + // Contact hardening: how far the casters lie from here sets how wide the blur is. + float wide = 0.0; + float disk = 1.0; + float spread = SHADOW_SPREAD * (softness - 0.25); + if (spread > 0.0) { + float search = clamp(SHADOW_SPREAD_BLOCKS / texelBlocks, softness + 1.0, SHADOW_SPREAD_TEXELS); + float turn = etnaDither() * 6.2831853; + vec2 blockers = etnaShadowBlockers(map, center, search, depth, slopePerTexel, bias, turn); + if (blockers.x < 0.5) { + // Nothing between here and the light within the widest blur. + return 1.0; + } + float apart = blockers.y / max(EtnaShadowInfo.z, 1e-6); + float radius = min(softness + spread * apart / texelBlocks, search); + wide = smoothstep(softness + 0.5, softness + 1.5, radius); + if (wide > 0.0) { + disk = etnaShadowDisk(map, center, radius, depth, slopePerTexel, bias, turn + 1.0); + if (wide >= 1.0) { + return mix(disk, 1.0, fade); + } + } + } vec2 lo = center - softness; vec2 hi = center + softness; ivec2 first = ivec2(floor(lo)); @@ -162,9 +275,31 @@ float etnaShadowFilter(sampler2D map, mat4 shadowMat, vec3 normal, bool thin) { } } #endif - float lit = sum.x / max(sum.y, 1e-4); + float lit = mix(sum.x / max(sum.y, 1e-4), disk, wide); // Fade out towards the edge of the area the map covers. - return mix(lit, 1.0, smoothstep(0.85, 1.0, edge)); + return mix(lit, 1.0, fade); +} + +// A low sun casts long shadows, and the sky, which it no longer outshines, fills them in: below about 20 +// degrees shadows fade, towards LOW_SUN_FILL lighter as the sun (or the moon) meets the horizon. Before +// 0.8.1 they kept their full darkness from 10 degrees up, at three to six times the length of what cast +// them. +const float LOW_SUN_FILL = 0.35; + +float etnaLowSunFill() { + return LOW_SUN_FILL * (1.0 - smoothstep(0.05, 0.35, ETNA_LIGHT_DIR.y)); +} + +// The shadow pass draws its casters from whole chunk columns round the camera (EtnaShadows.collectCasters): +// the shadow distance plus 14 blocks, rounded up to whole 16-block sections, along world x and z, so at +// least that far from the camera on every side. Under a sun below about 60 degrees the map reaches +// further than that towards and away from the sun, where nothing was drawn into it, and shadows stopped +// at a straight line there. They fade out before it instead. How much of the way to lit, for a +// camera-relative position. +float etnaCasterFade(vec3 position) { + float reach = 16.0 * ceil((EtnaShadowInfo.y + 14.0) / 16.0); + float away = max(abs(position.x), abs(position.z)); + return smoothstep(reach - 24.0, reach - 4.0, away); } // 1 where the sun reaches this fragment, 0 where something closer to the sun blocks it. The map holds @@ -178,7 +313,7 @@ float shadowVisibility(vec3 normal, bool thin) { if (EtnaShadowFade.x > 0.0) { lit = mix(lit, etnaShadowFilter(EtnaShadowSampler2, EtnaShadowMat2, normal, thin), EtnaShadowFade.x); } - return lit; + return mix(lit, 1.0, max(etnaLowSunFill(), etnaCasterFade(etnaViewPos))); } // shadowSpace for a point already in the map's clip space (EtnaShadowMat applied; the projection is @@ -205,7 +340,7 @@ vec4 shadowSpace(vec3 position) { // The unfiltered test in `map` for a point from shadowSpace (or the same for the second map): 1 lit, 0 in // shadow. float etnaShadowTest(sampler2D map, vec4 point) { - if (point.w >= 1.0) { + if (point.w >= 1.0 || point.z <= 0.0) { return 1.0; } float stored = texture(map, point.xy).r; @@ -227,7 +362,7 @@ float shadowAt(vec3 position) { if (EtnaShadowFade.x > 0.0) { lit = mix(lit, etnaShadowTest2(position), EtnaShadowFade.x); } - return lit; + return mix(lit, 1.0, etnaCasterFade(position)); } // shadowAt for one of many jittered samples that are averaged (the light shafts under water): it reads @@ -257,6 +392,9 @@ float etnaLeafThrough(sampler2D map, mat4 shadowMat) { #else float depth = ndc.z * 0.5 + 0.5; #endif + if (depth <= 0.0) { + return 1.0; + } float size = 1.0 / max(EtnaShadowInfo.x, 1e-6); vec2 center = (ndc.xy / etnaShadowFactor(ndc.xy) * 0.5 + 0.5) * size; vec2 lo = center - 1.0; diff --git a/src/main/resources/assets/etna/shaders/post/shafts.fsh b/src/main/resources/assets/etna/shaders/post/shafts.fsh index bacf2cb..ce966dd 100644 --- a/src/main/resources/assets/etna/shaders/post/shafts.fsh +++ b/src/main/resources/assets/etna/shaders/post/shafts.fsh @@ -66,6 +66,10 @@ float sunAt(vec3 clip) { #else float depth = clip.z * 0.5 + 0.5; #endif + // Beyond the far end of the map's depth range the cleared map holds nothing: lit, as in the terrain. + if (depth <= 0.0) { + return 1.0; + } // Reversed depth: bigger is closer to the light. float stored = textureLod(EtnaShadowSampler, uv, 0.0).r; float lit = stored > depth + EtnaShadowInfo.z * 0.05 ? 0.0 : 1.0; From f7d3861a8114563393f460a433af23e5dbcad95c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 22:35:07 +0000 Subject: [PATCH 03/24] Etna 0.8.1: keep the casters the wider shadow blur reads The shadows that soften with distance read the map up to 1.2 blocks round a point and compare it with the surface's plane over that disc, but the shadow pass still dropped casters by the old filter's reach (softness + 1 texels). Near the camera that is a few hundredths of a block, so a caster just outside the view, or a little below everything on screen, could be left out of the map while a pixel's blur still read where it would be: part of a soft edge missing at the side of the screen, coming back as the camera turns. The view test's margins (sideways and behind) and the receiver floor now take the wider reach into account; nothing changes at the lowest softness, where the wider blur is off. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Xzo7zbTpyU7a3y3XQNbw1n --- src/main/java/it/cosimo/etna/EtnaShadows.java | 61 +++++++++++++++++-- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/src/main/java/it/cosimo/etna/EtnaShadows.java b/src/main/java/it/cosimo/etna/EtnaShadows.java index 769eecd..2e53604 100644 --- a/src/main/java/it/cosimo/etna/EtnaShadows.java +++ b/src/main/java/it/cosimo/etna/EtnaShadows.java @@ -157,6 +157,12 @@ public boolean bilinear() { private static final float DISTORTION = 0.85F; /** Fewest blocks a shadow caster is kept below the lowest thing on screen (see receiverFloor). */ private static final int FLOOR_MARGIN = 16; + /** + * The widest blur of the shadows that soften with distance from their caster, in blocks + * (SHADOW_SPREAD_BLOCKS in terrain_shadow.glsl), and the shadow softness at which they are off. + */ + private static final float SPREAD_BLOCKS = 1.2F; + private static final float SPREAD_OFF_SOFTNESS = 0.25F; /** * How far the sun may move the shadow of something STEP_HEIGHT blocks up, on level ground, before the map * follows it (chooseMap): about a texel of the map 20 blocks from the camera at Ultra, three right by it. @@ -835,7 +841,8 @@ private static int receiverFloor(Vec3 cam, Matrix4f rotation, float radius, int // of the map there. Sideways: its box (softness + 1 texels each way) plus the 1.2-texel push off the // surface, along both axes. Along the light: its receiver plane, at most 8 texels of depth per texel // over softness + 0.5 texels on each axis. The leaf light (a 3x3 box) and the shafts (one texel) - // reach less. + // reach less; the shadows that soften with distance reach further near the middle of the map, where + // the texels are small (floorMargin). float reachTexels = 1.415F * (softness + 2.2F) + 16.0F * (softness + 0.5F); // A texel at the edge of the map, in blocks; the distortion shrinks it towards the middle. float edgeTexel = 2.0F * radius / size / (1.0F - DISTORTION); @@ -857,7 +864,7 @@ private static int receiverFloor(Vec3 cam, Matrix4f rotation, float radius, int // The most any section can need: a section that can't lower the floor even with it is skipped without // the arithmetic. float cornerFactor = 1.0F - DISTORTION + DISTORTION * farthest; - int largestMargin = Math.max(FLOOR_MARGIN, Mth.ceil(edgeTexel * cornerFactor * cornerFactor * reachTexels) + 2); + int largestMargin = floorMargin(softness, edgeTexel * cornerFactor * cornerFactor, reachTexels, radius); float r00 = rotation.m00(); float r10 = rotation.m10(); float r20 = rotation.m20(); @@ -884,13 +891,52 @@ private static int receiverFloor(Vec3 cam, Matrix4f rotation, float radius, int // as above). float far = Math.min(((float) Math.sqrt(lx * lx + ly * ly) + 15.0F + push) / radius, farthest); float factor = 1.0F - DISTORTION + DISTORTION * far; - margin = Math.max(FLOOR_MARGIN, Mth.ceil(edgeTexel * factor * factor * reachTexels) + 2); + margin = floorMargin(softness, edgeTexel * factor * factor, reachTexels, radius); } floor = Math.min(floor, y - margin); } return floor; } + /** + * receiverFloor's margin below a section whose lookups land on texels up to texelBlocks wide: the filter's + * reach (reachTexels of those texels), or the wider one of the shadows that soften with distance, sideways + * and behind, plus two blocks; at least FLOOR_MARGIN. + */ + private static int floorMargin(float softness, float texelBlocks, float reachTexels, float radius) { + float spread = spreadSideways(softness, texelBlocks, radius) + spreadBehind(softness, texelBlocks); + return Math.max(FLOOR_MARGIN, Mth.ceil(Math.max(texelBlocks * reachTexels, spread)) + 2); + } + + /** + * How far sideways, in blocks, the shadows that soften with distance (terrain_shadow.glsl) read the map round + * a point whose texels are at most texelBlocks wide, in a map `radius` blocks across each way; 0 when they + * are off. They look for casters SPREAD_BLOCKS round the point, counted in texels of the point's own size; + * further out the texels grow, by the most near the middle of the map, where the distortion grows them + * fastest. Then the push off the surface (up to 1.5 texels) and the four texels round each read (up to 2.2 + * texels further). + */ + private static float spreadSideways(float softness, float texelBlocks, float radius) { + if (!(softness > SPREAD_OFF_SOFTNESS)) { + return 0.0F; + } + float grow = 1.0F + DISTORTION * (SPREAD_BLOCKS + 2.0F) / ((1.0F - DISTORTION) * radius); + return SPREAD_BLOCKS * grow * grow + 4.0F * texelBlocks; + } + + /** + * How far behind a point, away from the light, the same shadows compare the map with the surface's plane, in + * blocks; 0 when they are off. The plane climbs at most 8 texels of depth per texel on each axis (the clamp + * in etnaShadowFilter); their disc reaches SPREAD_BLOCKS, or softness + 1 texels where that is wider, plus a + * texel on each axis for the reads' own texels. + */ + private static float spreadBehind(float softness, float texelBlocks) { + if (!(softness > SPREAD_OFF_SOFTNESS)) { + return 0.0F; + } + return 8.0F * (1.4143F * Math.max(SPREAD_BLOCKS, (softness + 1.0F) * texelBlocks) + 2.0F * texelBlocks); + } + /** Sort key: nearest the light (largest z) first, then the order found, which the low 32 bits give back. */ private static long casterKey(float towardsLight, int index) { int bits = Float.floatToIntBits(-towardsLight); @@ -1084,10 +1130,13 @@ private static boolean setMargins(float radius, int size, float softness, float float texelBlocks = Math.min(texelAt(far + 2.0F * look, radius, edgeTexel), largestTexel); // Sideways: the filter, the leaf light, and the light from one block above a point under water // (terrain_water.glsl), with the texel its point lands in. - side[b] = Math.max(filter * texelBlocks, 1.0F + 0.71F * texelBlocks) + SLOP; + // The shadows that soften with distance read wider still. + side[b] = Math.max(Math.max(filter * texelBlocks, 1.0F + 0.71F * texelBlocks), spreadSideways(softness, texelBlocks, radius)) + + SLOP; // The filter compares each texel with the receiver's own plane there, which can lie up to 8 texels - // of depth per texel further from the light, over softness + 0.5 texels on each axis. - behind[b] = 16.0F * (softness + 0.5F) * texelBlocks; + // of depth per texel further from the light, over softness + 0.5 texels on each axis (over the + // wider disc, for the shadows that soften with distance). + behind[b] = Math.max(16.0F * (softness + 0.5F) * texelBlocks, spreadBehind(softness, texelBlocks)); // The water's depth: the four texels around the surface point, and twelve points up to 4 blocks // from it on the water's plane (THICKNESS_RING in terrain_water.glsl), with the texel each lands in. water[b] = Math.max(4.0F + 0.71F * texelBlocks, DIAGONAL * texelBlocks) + SLOP; From cf8126f0b0c45564df216cb339789cf1d03db9ce Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 02:03:33 +0000 Subject: [PATCH 04/24] Etna 0.8.1: steadier, cheaper soft shadows; own shadow warp and Fresnel Contact hardening no longer turns its reads every frame or gives up where a few reads find nothing: the box filter now also reports blockers, six fixed bilinear search reads cover the ring beyond it, the disc (16 reads, at most 16 texels) is skipped where everything read is blocked or open, and only discs wider than about eight texels turn, by more the wider they are. In the offline bench the flicker after TAA is 35-65 % below the tested build's and below 0.8.0's with a low sun. The shadow map warp and the water's fresnelDielectric are rewritten from first principles and the Cook-Torrance form, without Photon's code: the warp's distance n = sqrt(1 - (1-x^2)(1-y^2)) fills the whole map and keeps far texels two to five times finer towards the corners. EtnaShadows' margins follow its bounds. CREDITS and README list them as Etna's own. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Xzo7zbTpyU7a3y3XQNbw1n --- CHANGELOG.md | 2 + CREDITS.md | 18 +- README.md | 4 +- src/main/java/it/cosimo/etna/EtnaShadows.java | 30 ++- .../shaders/include/shadow_distortion.glsl | 32 ++- .../etna/shaders/include/terrain_shadow.glsl | 184 +++++++++--------- .../etna/shaders/include/terrain_water.glsl | 27 +-- 7 files changed, 158 insertions(+), 139 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fd78b3..883936b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ Every release is for Minecraft 26.4-snapshot-3 with Fabric. Versions: the middle Fixes for 0.8.0's water, and shadows that soften with distance. * **Shadows soften with distance, as real ones do.** A shadow is sharp where it meets what casts it and blurs the further it reaches: a tree's shadow is crisp at the trunk and a soft smudge at its far end, and long evening shadows lose their detail and fade towards the tip. Before, every shadow edge had the same blur. Shadow Softness now also sets how quickly they soften; at its lowest, edges stay hard everywhere. +* **Steadier, cheaper soft shadows.** The test build turned its shadow reads every frame and gave up where its few reads found nothing, so the softened edges shimmered and cost up to 2.5 ms a frame. Now the search for what casts a shadow reads the same spots every frame, open ground and the inside of shadows skip the wide blur, and only very wide evening blurs still turn, gently. In Etna's shadow test bench the flicker after anti-aliasing is 35 to 65 % lower than the test build's and, with the sun low, lower than 0.8.0's too. +* **Sharper far shadows.** The shadow map is spread over the land in a new way: about as sharp round you as before, two to five times sharper towards the corners of the shadowed area, and the whole map is used (before, about 6 % of it was never filled). * **Long low-sun shadows are fainter.** Below about 20 degrees the sky fills shadows in a little, up to a third lighter at the horizon. * Far shadows fade out instead of stopping at a straight line about 180 blocks away when looking towards or away from a low sun, and ground more than 320 blocks below the camera along the light (far away under a low sun, or seen from very high up) no longer turns dark. diff --git a/CREDITS.md b/CREDITS.md index 9f44dfa..87762dd 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -1,8 +1,7 @@ # Credits Etna is original code under the MIT licence (`LICENSE`), with these exceptions: the temporal anti-aliasing -pass, the shadow map distortion and the water's Fresnel function are ported from another shader pack, under -that pack's own licence; the sky's star hash is David Hoskins's, under the MIT licence; and the terrain and +pass is ported from another shader pack, under that pack's own licence; the sky's star hash is David Hoskins's, under the MIT licence; and the terrain and cloud shaders are based on Minecraft's own, which Etna's licence doesn't cover. ## Code ported from other projects @@ -10,10 +9,8 @@ cloud shaders are based on Minecraft's own, which Etna's licence doesn't cover. | Etna file | Source | Licence | |---|---|---| | `src/main/resources/assets/etna/shaders/post/taa.fsh` | [Photon Shaders](https://github.com/sixthsurge/photon) by SixthSurge: `program/c4_taa_exposure.fsh` (TAA, neighbourhood and variance clipping, offcenter rejection) and `include/utility/bicubic.glsl` (fast Catmull-Rom history filter), adapted to Minecraft's reversed depth buffer and Etna's camera matrices | Photon Shaders License, see `LICENSE-PHOTON.txt` | -| `src/main/resources/assets/etna/shaders/include/shadow_distortion.glsl`: `etnaShadowFactor`, used in `src/main/resources/assets/etna/shaders/core/terrain.vsh` (shadow pass), `src/main/resources/assets/etna/shaders/include/terrain_shadow.glsl` (shadow map lookups) and `src/main/resources/assets/etna/shaders/post/shafts.fsh` (the light shafts' lookups) | [Photon Shaders](https://github.com/sixthsurge/photon) by SixthSurge: `include/lighting/shadows/distortion.glsl` (quartic-length shadow map distortion) | Photon Shaders License, see `LICENSE-PHOTON.txt` | | `src/main/resources/assets/etna/shaders/core/sky.fsh`: `hash33` (where the stars sit, how bright they are, their colour) | "Hash without Sine" by David Hoskins ([shadertoy.com/view/4djSRW](https://www.shadertoy.com/view/4djSRW)), `hash33`, unchanged | MIT, Copyright (c) 2014 David Hoskins, licence text as in `LICENSE` | | `src/main/resources/assets/etna/shaders/core/sky.vsh`: `hash41` (when and where a shooting star flares, which way it falls) | "Hash without Sine" by David Hoskins ([shadertoy.com/view/4djSRW](https://www.shadertoy.com/view/4djSRW)), `hash41`, unchanged | MIT, Copyright (c) 2014 David Hoskins, licence text as in `LICENSE` | -| `src/main/resources/assets/etna/shaders/include/terrain_water.glsl`: `fresnelDielectric` | [Photon Shaders](https://github.com/sixthsurge/photon) by SixthSurge: `include/lighting/bsdf.glsl`, `fresnel_dielectric_n` (Fresnel reflectance with total internal reflection, for Snell's window seen from under water), with floored divisors and a clamp added | Photon Shaders License, see `LICENSE-PHOTON.txt` | | `src/main/resources/assets/etna/shaders/post/shafts.fsh`: `SUBPIXEL` (the order in which the light shafts' half-resolution pixels take turns) | [Photon Shaders](https://github.com/sixthsurge/photon) by SixthSurge: `checkerboard.glsl`, the same 2×2 order | Photon Shaders License, see `LICENSE-PHOTON.txt` | | `src/main/resources/assets/etna/shaders/core/terrain.vsh`, `terrain.fsh`, `clouds.vsh`, `clouds.fsh`, `weather.vsh`, `weather.fsh` | Minecraft's vanilla `terrain`, `clouds` and `particle` core shaders, which Etna replaces while it is on and extends (`weather` is made from `particle`, which vanilla draws rain and snow with) | Based on Minecraft's vanilla shader, (c) Mojang AB, not covered by Etna's MIT licence | @@ -59,6 +56,14 @@ first, or original replacements for them. depth buffer, signed so each line falls on the nearer side. The pack families and their colour traits come from Etna's own study of the packs' published screenshots; no pack assets are used. * Noise: interleaved gradient noise (Jorge Jimenez, 2014). +* Shadows (`shadow_distortion.glsl`, `terrain_shadow.glsl`, 0.8.1): the shadow map's distortion is Etna's + own. A texel's size grows as (r + c)², which keeps up with the ground's foreshortening away from the camera + (the classic perspective-matching idea of shadow map warping); r is sqrt(1 - (1 - x²)(1 - y²)), which is + the plain distance from the map's centre near it and reaches 1 all along the map's square edge, so the + whole map is used and it never folds over. The soft edges are an area-weighted box of texels against a + receiver plane, and contact hardening follows percentage-closer soft shadows (Randima Fernando, + "Percentage-Closer Soft Shadows", NVIDIA, 2005), with a fixed search pattern and a disc turned only where + it is wide. * Shadow casters (`EtnaShadows.java`): only the chunk sections whose shadow can fall on something the camera sees are drawn into the shadow map. The view is extruded towards the light: the sides of the view that face away from the light, and a plane along the light through each edge where such a side meets one @@ -98,6 +103,11 @@ first, or original replacements for them. depth as the guide (Johannes Kopf, Michael F. Cohen, Dani Lischinski and Matt Uyttendaele, "Joint Bilateral Upsampling", SIGGRAPH 2007). The mist's look (valley mist at dawn and dusk, haze at night and in rain, cave fog) follows Complementary Reimagined; no code from it. +* Water Fresnel (`terrain_water.glsl`, `fresnelDielectric`, 0.8.1): the exact Fresnel reflectance of + unpolarised light at a dielectric boundary, in the form Robert Cook and Kenneth Torrance give ("A + Reflectance Model for Computer Graphics", 1982; also Walter, Marschner, Li and Torrance, "Microfacet + Models for Refraction through Rough Surfaces", 2007, eq. 22), with total internal reflection for + Snell's window seen from under water. * Under water (`terrain_water.glsl`), ideas after Photon with Etna's own code: the phase function of the light shafts, 0.7 × Henyey-Greenstein (g = 0.5) + 0.3 isotropic, as Photon's water; caustics as the change in area of light refracted by the surface (the idea of Photon's `program/shadow.fsh`, diff --git a/README.md b/README.md index 9a6f49b..1a7261d 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ No. Etna works on its own; Vesuvio makes the game, and Etna's shadows, faster. Etna's own code is under the MIT licence, see [LICENSE](LICENSE). Its terrain and cloud shaders are based on Minecraft's vanilla shaders (© Mojang AB), which Etna's licence doesn't cover. A few small parts are ported from -[Photon Shaders](https://github.com/sixthsurge/photon) by SixthSurge (temporal anti-aliasing, shadow map -distortion, the water's Fresnel function) and stay under the Photon Shaders License (`LICENSE-PHOTON.txt`). +[Photon Shaders](https://github.com/sixthsurge/photon) by SixthSurge (temporal anti-aliasing, the order of +the light shafts' half-resolution pixels) and stay under the Photon Shaders License (`LICENSE-PHOTON.txt`). Complementary Reimagined inspired the look; none of its code is used. Everything taken from other projects and every technique Etna follows is listed in [CREDITS.md](CREDITS.md). diff --git a/src/main/java/it/cosimo/etna/EtnaShadows.java b/src/main/java/it/cosimo/etna/EtnaShadows.java index 2e53604..e7440dc 100644 --- a/src/main/java/it/cosimo/etna/EtnaShadows.java +++ b/src/main/java/it/cosimo/etna/EtnaShadows.java @@ -849,22 +849,14 @@ private static int receiverFloor(Vec3 cam, Matrix4f rotation, float radius, int // shadowVisibility pushes its lookup 1.2 texels off the surface (texels of the map where the receiver // is) before it checks for the edge of the map. So a receiver up to that far outside the map still // reads it, and a lookup can lie that much further out than its receiver. The push is largest where the - // texels are largest: at the map's corners (1.19 in map units), or a little beyond them for a receiver - // outside the map. How far beyond grows with the push itself; three rounds settle it to well under a - // hundredth of a block, even for the coarsest map etna.properties allows (512 texels over 256 blocks). - float farthest = 1.19F; - float push = 0.0F; - for (int round = 0; round < 3; round++) { - float edgeFactor = 1.0F - DISTORTION + DISTORTION * farthest; - push = 1.2F * edgeTexel * edgeFactor * edgeFactor; - farthest = 1.19F * (1.0F + push / radius); - } + // texels are largest: on the map's edge and past it, where the warp (shadow_distortion.glsl) is at its + // coarsest and a texel is edgeTexel wide. + float push = 1.2F * edgeTexel; // A section centre further out than this, on either axis, has no point whose lookup lands in the map. float mapEdge = radius + 15.0F + push; // The most any section can need: a section that can't lower the floor even with it is skipped without // the arithmetic. - float cornerFactor = 1.0F - DISTORTION + DISTORTION * farthest; - int largestMargin = floorMargin(softness, edgeTexel * cornerFactor * cornerFactor, reachTexels, radius); + int largestMargin = floorMargin(softness, edgeTexel, reachTexels, radius); float r00 = rotation.m00(); float r10 = rotation.m10(); float r20 = rotation.m20(); @@ -887,9 +879,9 @@ private static int receiverFloor(Vec3 cam, Matrix4f rotation, float radius, int int margin = FLOOR_MARGIN; if (Math.abs(lx) <= mapEdge && Math.abs(ly) <= mapEdge) { // Read through the map, so filtered there: its texels are largest at its lookup farthest from the - // middle of the map, which is its own farthest point pushed out once more (in map units, capped - // as above). - float far = Math.min(((float) Math.sqrt(lx * lx + ly * ly) + 15.0F + push) / radius, farthest); + // middle of the map, which is its own farthest point pushed out once more (in map units; the + // warp's distance never passes 1, see texelAt). + float far = Math.min(((float) Math.sqrt(lx * lx + ly * ly) + 15.0F + push) / radius, 1.0F); float factor = 1.0F - DISTORTION + DISTORTION * far; margin = floorMargin(softness, edgeTexel * factor * factor, reachTexels, radius); } @@ -1098,9 +1090,9 @@ private static void addPlane(float x, float y, float z, float w, float towardsLi * The margins for each band of distance from the middle of the map. A reader of the map looks a number * of texels around a point (its anchor), and the distortion makes texels larger away from the middle: * within d blocks of it a texel is at most edgeTexel * f^2 blocks wide in any direction, with - * f = 0.15 + 0.85 d / radius (shadow_distortion.glsl takes f from the quartic length, which is never - * larger). How far a reader looks so depends on how far out it looks: settle finds a reach that covers - * itself. + * f = 0.15 + 0.85 min(d / radius, 1) (shadow_distortion.glsl takes f from a distance of its own that + * is never larger than either, and its texels are never wider than f^2 makes them). How far a reader + * looks so depends on how far out it looks: settle finds a reach that covers itself. */ private static boolean setMargins(float radius, int size, float softness, float reach) { float texel = 2.0F * radius / size; @@ -1169,7 +1161,7 @@ private static float settle(float distance, float times, float radius, float edg /** The widest a texel of the map gets within {@code distance} blocks of its middle, in blocks. */ private static float texelAt(float distance, float radius, float edgeTexel) { - float factor = 1.0F - DISTORTION + DISTORTION * distance / radius; + float factor = 1.0F - DISTORTION + DISTORTION * Math.min(distance / radius, 1.0F); return edgeTexel * factor * factor; } diff --git a/src/main/resources/assets/etna/shaders/include/shadow_distortion.glsl b/src/main/resources/assets/etna/shaders/include/shadow_distortion.glsl index 25f9c35..363fbb4 100644 --- a/src/main/resources/assets/etna/shaders/include/shadow_distortion.glsl +++ b/src/main/resources/assets/etna/shaders/include/shadow_distortion.glsl @@ -1,13 +1,27 @@ -// Shadow map distortion, after Photon (SixthSurge, Photon Shaders License, see CREDITS.md): the shadow -// pass (terrain.vsh) squeezes the map towards its centre, where the player stands, and the lookups -// (terrain_shadow.glsl) undo it with the same function. A shadow texel next to you covers about a -// seventh of what it did undistorted, so the stair-steps of a block's shadow edge are far too small to -// see crawl as the sun moves; far away the texels grow instead. -// terrain.vsh includes it in the shadow pass (ETNA_SHADOW_PASS), terrain.fsh in the shadow-receiving -// draws (ETNA_SHADOWS). +// How Etna spreads its shadow map over the land: fine texels round the camera, coarser ones further out. +// Etna's own warp (0.8.1). The ground d blocks from a camera above it is seen ever more edge-on, so along +// the line of sight a pixel there covers about d^2 times as much of it as right below the camera, and a +// shadow texel can grow about as fast before it shows. The warp r' = r / (k r + 1 - k) of the map's +// square gives that: its slope, (1 - k) / (k r + 1 - k)^2, falls as (r + c)^-2 with c = (1 - k) / k of the +// map's half-width, it enlarges the middle 1 / (1 - k) times, and it leaves r = 1 where it is. +// +// r is a distance of Etna's own, n = sqrt(1 - (1 - x^2)(1 - y^2)): near the middle the product of the two +// brackets is 1 - x^2 - y^2 up to fourth-order terms, so n is the ordinary distance there and shadows are +// as sharp in every direction round the camera; all along the square's edge one bracket is 0, so n is 1 +// there and the warp keeps the square's edge where it is and fills the whole map, corners too. In between +// n lies between the larger of |x| and |y| and the ordinary distance, and along every line out from the +// middle it grows, never faster in proportion than the distance itself: the warp keeps every point on its +// line and in its order there, so it never folds. Past the edge (a corner of a caster outside the square) the +// brackets stop at 0 and n stays 1: the warp leaves those points where they are, outside the map. +// terrain.vsh includes this in the shadow pass (ETNA_SHADOW_PASS), terrain.fsh in the shadow-receiving draws +// (ETNA_SHADOWS) and post/shafts.fsh for the light shafts' lookups. + +// k above: how strongly the middle of the map is enlarged. EtnaShadows.DISTORTION holds the same value. const float ETNA_SHADOW_DISTORTION = 0.85; +// The warp's divisor at point p of the map's square (-1 to 1 on each axis, before the warp): the map +// holds p at p / etnaShadowFactor(p). Between 1 - k in the middle and 1 on the square's edge and beyond. float etnaShadowFactor(vec2 p) { - vec2 p2 = p * p; - return sqrt(sqrt(p2.x * p2.x + p2.y * p2.y)) * ETNA_SHADOW_DISTORTION + (1.0 - ETNA_SHADOW_DISTORTION); + vec2 inside = max(1.0 - p * p, 0.0); + return ETNA_SHADOW_DISTORTION * sqrt(1.0 - inside.x * inside.y) + (1.0 - ETNA_SHADOW_DISTORTION); } diff --git a/src/main/resources/assets/etna/shaders/include/terrain_shadow.glsl b/src/main/resources/assets/etna/shaders/include/terrain_shadow.glsl index 6ea16e3..b18ef43 100644 --- a/src/main/resources/assets/etna/shaders/include/terrain_shadow.glsl +++ b/src/main/resources/assets/etna/shaders/include/terrain_shadow.glsl @@ -29,14 +29,17 @@ vec2 etnaFilterAxis(int t, float lo, float hi, float center) { } // One texel of the filter, from its column and its row (etnaFilterAxis): x is the part of it that is -// lit, y its weight, the share of the texel the square covers. The texel counts as lit unless the map -// holds something closer to the light there than the receiver plane at the texel's centre, by more -// than the bias. Both ways of reading the map add up their texels through this. -vec2 etnaFilterTexel(float stored, vec2 column, vec2 row, float depth, vec2 slope, float bias) { +// lit, y its weight, the share of the texel the square covers; z is the part of it that is blocked and +// w that times how far in front of the receiver the blocker lies (in depth units), which the contact +// hardening below uses to tell how far the shadow has reached from what casts it. The texel counts as +// blocked when the map holds something closer to the light there than the receiver plane at the +// texel's centre, by more than the bias. Both ways of reading the map add up their texels through this. +vec4 etnaFilterTexel(float stored, vec2 column, vec2 row, float depth, vec2 slope, float bias) { float w = column.x * row.x; // Reversed depth: bigger is closer to the light. - float expected = depth + dot(slope, vec2(column.y, row.y)); - return vec2(stored > expected + bias ? 0.0 : w, w); + float front = stored - depth - dot(slope, vec2(column.y, row.y)); + float blocked = front > bias ? w : 0.0; + return vec4(w - blocked, w, blocked, blocked * front); } #ifdef ETNA_SHADOW_GATHER @@ -45,7 +48,7 @@ vec2 etnaFilterTexel(float stored, vec2 column, vec2 row, float depth, vec2 slop // rounding in the texture unit can pick another one. Its components are the block's texels (0, 1), // (1, 1), (1, 0) and (0, 0), in that order. The map's sampler clamps to the edge, as the texelFetch // loop clamps its indices. -vec2 etnaFilterGather(sampler2D map, int x, int y, vec2 lo, vec2 hi, vec2 center, float depth, vec2 slope, float bias) { +vec4 etnaFilterGather(sampler2D map, int x, int y, vec2 lo, vec2 hi, vec2 center, float depth, vec2 slope, float bias) { vec4 stored = textureGather(map, vec2(x + 1, y + 1) * max(EtnaShadowInfo.x, 1e-6)); vec2 column0 = etnaFilterAxis(x, lo.x, hi.x, center.x); vec2 column1 = etnaFilterAxis(x + 1, lo.x, hi.x, center.x); @@ -62,26 +65,46 @@ vec2 etnaFilterGather(sampler2D map, int x, int y, vec2 lo, vec2 hi, vec2 center // Real shadows are sharp where they meet what casts them and blur the further they reach from it: the sun // is a disc, not a point, so the edge's blur grows with the distance between the caster and the ground. // A tree's shadow is crisp at its trunk and a soft smudge at its far end, and a long evening shadow -// loses its detail and fades out towards its tip. Before 0.8.1 every edge had the same blur, at the -// trunk and 25 blocks away. The idea is percentage-closer soft shadows (Randima Fernando, "Percentage- -// Closer Soft Shadows", NVIDIA, 2005), written here for Etna's map: a few reads round the point find -// how far in front of it, towards the light, the casters lie on average; the blur's radius is the shadow +// loses its detail and fades out towards its tip. The idea is percentage-closer soft shadows (Randima +// Fernando, "Percentage-Closer Soft Shadows", NVIDIA, 2005), written here for Etna's map. The box filter +// below reads the texels round the point anyway; it and SHADOW_SEARCH reads further out find how far in +// front of the point, towards the light, the casters lie on average. The blur's radius is the shadow // softness setting plus SHADOW_SPREAD of that distance, times how far the setting lies above its lowest -// (0.25: hard shadow edges stay hard, and skip all of this); the near part of a shadow keeps the exact area-weighted box filter below, the wide part reads -// SHADOW_DISK points spread over a disc of that radius, and the two blend where the blur widens. +// (0.25: hard shadow edges stay hard, and skip all of this). The near part of a shadow keeps the exact +// area-weighted box filter, the wide part reads SHADOW_DISK points spread over a disc of that radius, and +// the two blend where the blur widens. +// +// Every read is the bilinear blend of the four texels round it, and the search's reads sit at the same +// place round the point in every frame, so how wide the blur is only changes when the point, the sun or +// the casters move, and then smoothly. So does the disc while it is narrow. A disc wider than about +// eight texels has gaps between its reads that the edge shows through as faint copies of itself; it is +// turned from pixel to pixel and frame to frame (etnaDither), by more the wider it is, and the anti- +// aliasing averages the turns into a smooth blur. 0.8.1's first version turned every read, the search's +// too, and gave up where its few reads found nothing in the way: the anti-aliasing could not settle +// that, and penumbras shimmered. // Blocks of blur radius per block between the caster and the ground, per unit of shadow softness above // its lowest: at the default 1.3 about five times the real sun's, which looks right at Minecraft's scale. const float SHADOW_SPREAD = 0.025; // The widest blur, in blocks and in texels of the map: the casters are looked for this far round the -// point. +// point. The wider the disc in texels, the more of its smoothness rests on the anti-aliasing. At the +// default map size and distance a texel near the camera is about a sixtieth of a block, so the blur +// there still reaches a quarter of a block; from about 35 blocks out the 1.2 blocks set the limit. const float SHADOW_SPREAD_BLOCKS = 1.2; -const float SHADOW_SPREAD_TEXELS = 32.0; -// Gathers (four texels each) that look for casters, the first at the point itself, and gathers that -// average a wide blur. -const int SHADOW_SEARCH = 8; -const int SHADOW_DISK = 12; -const float SHADOW_GOLDEN_ANGLE = 2.39996323; +const float SHADOW_SPREAD_TEXELS = 16.0; +// Where the reads lie, as fractions of the search radius and of the blur's radius. Both sets are point +// symmetric (each read has its opposite), so a receiver plane that is a little off cancels out. The search +// covers the ring beyond the box filter; the disc's sixteen points sit on eight rings of equal area. +const int SHADOW_SEARCH = 6; +const vec2 SHADOW_SEARCH_TAPS[6] = vec2[6]( + vec2(0.5401, 0.0000), vec2(0.0663, -0.7554), vec2(-0.9123, -0.1614), + vec2(-0.5401, 0.0000), vec2(-0.0663, 0.7554), vec2(0.9123, 0.1614)); +const int SHADOW_DISK = 16; +const vec2 SHADOW_DISK_TAPS[16] = vec2[16]( + vec2(0.2500, 0.0000), vec2(-0.3193, 0.2925), vec2(0.0489, -0.5569), vec2(0.4024, 0.5249), + vec2(-0.7385, -0.1306), vec2(0.6996, -0.4450), vec2(-0.2340, 0.8705), vec2(-0.4463, -0.8593), + vec2(-0.2500, -0.0000), vec2(0.3193, -0.2925), vec2(-0.0489, 0.5569), vec2(-0.4024, -0.5249), + vec2(0.7385, 0.1306), vec2(-0.6996, 0.4450), vec2(0.2340, -0.8705), vec2(0.4463, 0.8593)); // The 2x2 texels round corner `corner` of the map (in texels; texel i spans i .. i + 1), in // textureGather's order: (-1, 0), (0, 0), (0, -1) and (-1, -1) from the corner. @@ -98,48 +121,20 @@ vec4 etnaShadowQuad(sampler2D map, vec2 corner) { #endif } -// The receiver plane's depth at the four texels of etnaShadowQuad(corner): depth at `center`, changing by -// `slope` per texel. -vec4 etnaShadowPlane(vec2 corner, vec2 center, float depth, vec2 slope) { +// One read of the search or the disc at `q` (in texels), the bilinear blend of the four texels round it, +// each compared with the receiver plane (depth at `center`, changing by `slope` per texel) at its own +// centre: as etnaFilterTexel, x the lit share, y the weight (1), z the blocked share and w that times how +// far in front the blockers lie. +vec4 etnaShadowTap(sampler2D map, vec2 q, vec2 center, float depth, vec2 slope, float bias) { + vec2 corner = floor(q - 0.5) + 1.0; + vec2 f = q - (corner - 0.5); float here = depth + dot(slope, corner - center); - return here + vec4(dot(slope, vec2(-0.5, 0.5)), dot(slope, vec2(0.5, 0.5)), dot(slope, vec2(0.5, -0.5)), dot(slope, vec2(-0.5, -0.5))); -} - -// What casts a shadow here, looked for in SHADOW_SEARCH gathers spread over a disc `radius` texels wide -// round `center` (a Vogel spiral turned by `turn`, the first at the centre): x, how many of the texels -// read lie in front of the receiver plane, towards the light; y, how far in front on average, in depth -// units. -vec2 etnaShadowBlockers(sampler2D map, vec2 center, float radius, float depth, vec2 slope, float bias, float turn) { - float found = 0.0; - float sum = 0.0; - for (int i = 0; i < SHADOW_SEARCH; i++) { - float r = sqrt(float(i) / float(SHADOW_SEARCH)) * radius; - float a = float(i) * SHADOW_GOLDEN_ANGLE + turn; - vec2 corner = floor(center + r * vec2(cos(a), sin(a)) + 0.5); - // Reversed depth: bigger is closer to the light. - vec4 front = etnaShadowQuad(map, corner) - etnaShadowPlane(corner, center, depth, slope); - vec4 hit = step(vec4(bias), front); - found += dot(hit, vec4(1.0)); - sum += dot(hit, front); - } - return vec2(found, sum / max(found, 1.0)); -} - -// The lit share over a disc `radius` texels wide round `center`: SHADOW_DISK points on a Vogel spiral -// turned by `turn`, each the bilinear blend of the four texels round it (so the result slides smoothly -// as the sun moves instead of stepping a texel at a time). -float etnaShadowDisk(sampler2D map, vec2 center, float radius, float depth, vec2 slope, float bias, float turn) { - float lit = 0.0; - for (int i = 0; i < SHADOW_DISK; i++) { - float r = sqrt((float(i) + 0.5) / float(SHADOW_DISK)) * radius; - float a = float(i) * SHADOW_GOLDEN_ANGLE + turn; - vec2 q = center + r * vec2(cos(a), sin(a)); - vec2 corner = floor(q - 0.5) + 1.0; - vec2 f = q - (corner - 0.5); - vec4 open = step(etnaShadowQuad(map, corner), etnaShadowPlane(corner, center, depth, slope) + bias); - lit += dot(open, vec4((1.0 - f.x) * f.y, f.x * f.y, f.x * (1.0 - f.y), (1.0 - f.x) * (1.0 - f.y))); - } - return lit / float(SHADOW_DISK); + vec4 plane = here + vec4(dot(slope, vec2(-0.5, 0.5)), dot(slope, vec2(0.5, 0.5)), dot(slope, vec2(0.5, -0.5)), dot(slope, vec2(-0.5, -0.5))); + // Reversed depth: bigger is closer to the light. + vec4 front = etnaShadowQuad(map, corner) - plane; + vec4 blocked = step(vec4(bias), front) * vec4((1.0 - f.x) * f.y, f.x * f.y, f.x * (1.0 - f.y), (1.0 - f.x) * (1.0 - f.y)); + float share = dot(blocked, vec4(1.0)); + return vec4(1.0 - share, 1.0, share, dot(blocked, front)); } // shadowVisibility on one of the two maps (`map`, drawn through `shadowMat`). @@ -205,50 +200,27 @@ float etnaShadowFilter(sampler2D map, mat4 shadowMat, vec3 normal, bool thin) { // anti-aliasing pass, which keeps only the history that fits this frame's neighbouring pixels, // dropped the averaged edge and let them flicker. vec2 center = uv * size; - float fade = smoothstep(0.85, 1.0, edge); - // Contact hardening: how far the casters lie from here sets how wide the blur is. - float wide = 0.0; - float disk = 1.0; - float spread = SHADOW_SPREAD * (softness - 0.25); - if (spread > 0.0) { - float search = clamp(SHADOW_SPREAD_BLOCKS / texelBlocks, softness + 1.0, SHADOW_SPREAD_TEXELS); - float turn = etnaDither() * 6.2831853; - vec2 blockers = etnaShadowBlockers(map, center, search, depth, slopePerTexel, bias, turn); - if (blockers.x < 0.5) { - // Nothing between here and the light within the widest blur. - return 1.0; - } - float apart = blockers.y / max(EtnaShadowInfo.z, 1e-6); - float radius = min(softness + spread * apart / texelBlocks, search); - wide = smoothstep(softness + 0.5, softness + 1.5, radius); - if (wide > 0.0) { - disk = etnaShadowDisk(map, center, radius, depth, slopePerTexel, bias, turn + 1.0); - if (wide >= 1.0) { - return mix(disk, 1.0, fade); - } - } - } vec2 lo = center - softness; vec2 hi = center + softness; ivec2 first = ivec2(floor(lo)); - vec2 sum = vec2(0.0); + vec4 box = vec4(0.0); #ifdef ETNA_SHADOW_GATHER // The square overlaps at most 2 texels a side up to softness 0.5, 4 up to 1.5, 6 up to 2.5, 8 up to // 3.5 and 9 up to 4, the most the menu allows. So one gather, or blocks of 2x2 to 5x5 gathers from // `first`, hold every texel the loop below gives any weight, and the ones past the square's edge // weigh nothing. The softness is the same for every pixel, so they all take the same branch. if (softness <= 0.5) { - sum = etnaFilterGather(map, first.x, first.y, lo, hi, center, depth, slopePerTexel, bias); + box = etnaFilterGather(map, first.x, first.y, lo, hi, center, depth, slopePerTexel, bias); } else if (softness <= 1.5) { for (int y = 0; y < 4; y += 2) { for (int x = 0; x < 4; x += 2) { - sum += etnaFilterGather(map, first.x + x, first.y + y, lo, hi, center, depth, slopePerTexel, bias); + box += etnaFilterGather(map, first.x + x, first.y + y, lo, hi, center, depth, slopePerTexel, bias); } } } else if (softness <= 2.5) { for (int y = 0; y < 6; y += 2) { for (int x = 0; x < 6; x += 2) { - sum += etnaFilterGather(map, first.x + x, first.y + y, lo, hi, center, depth, slopePerTexel, bias); + box += etnaFilterGather(map, first.x + x, first.y + y, lo, hi, center, depth, slopePerTexel, bias); } } } else { @@ -261,7 +233,7 @@ float etnaShadowFilter(sampler2D map, mat4 shadowMat, vec3 normal, bool thin) { vec2 capped = min(hi, vec2(first + 9)); for (int y = 0; y < span; y += 2) { for (int x = 0; x < span; x += 2) { - sum += etnaFilterGather(map, first.x + x, first.y + y, lo, capped, center, depth, slopePerTexel, bias); + box += etnaFilterGather(map, first.x + x, first.y + y, lo, capped, center, depth, slopePerTexel, bias); } } } @@ -271,13 +243,41 @@ float etnaShadowFilter(sampler2D map, mat4 shadowMat, vec3 normal, bool thin) { vec2 row = etnaFilterAxis(y, lo.y, hi.y, center.y); for (int x = first.x; x <= last.x; x++) { float stored = texelFetch(map, clamp(ivec2(x, y), ivec2(0), ivec2(size) - 1), 0).r; - sum += etnaFilterTexel(stored, etnaFilterAxis(x, lo.x, hi.x, center.x), row, depth, slopePerTexel, bias); + box += etnaFilterTexel(stored, etnaFilterAxis(x, lo.x, hi.x, center.x), row, depth, slopePerTexel, bias); } } #endif - float lit = mix(sum.x / max(sum.y, 1e-4), disk, wide); + float lit = box.x / max(box.y, 1e-4); + // Contact hardening: how far the casters lie from here sets how wide the blur is. + float spread = SHADOW_SPREAD * (softness - 0.25); + if (spread > 0.0) { + float search = clamp(SHADOW_SPREAD_BLOCKS / texelBlocks, softness + 1.0, SHADOW_SPREAD_TEXELS); + vec4 found = box; + for (int i = 0; i < SHADOW_SEARCH; i++) { + found += etnaShadowTap(map, center + SHADOW_SEARCH_TAPS[i] * search, center, depth, slopePerTexel, bias); + } + // Everything read is in the way (deep inside a shadow) or nothing is (in the open): the disc + // would find the same, so it is only read where the two meet. + if (found.z > 0.0 && found.x > 0.0) { + float apart = found.w / (found.z * max(EtnaShadowInfo.z, 1e-6)); + float radius = min(softness + spread * apart / texelBlocks, search); + // A blocker only grazed by the reads (or a gap only grazed, deep in a shadow) widens the blur + // only that much, so the disc fades in where it starts being read. + float wide = smoothstep(softness + 0.5, softness + 1.5, radius) * smoothstep(0.0, 0.5, min(found.x, found.z)); + if (wide > 0.0) { + // The disc is point symmetric, so half a turn covers every way it can lie. + float angle = etnaDither() * 3.14159265 * smoothstep(4.0, 8.0, radius); + mat2 turn = mat2(cos(angle), sin(angle), -sin(angle), cos(angle)) * radius; + float disk = 0.0; + for (int i = 0; i < SHADOW_DISK; i++) { + disk += etnaShadowTap(map, center + turn * SHADOW_DISK_TAPS[i], center, depth, slopePerTexel, bias).x; + } + lit = mix(lit, disk / float(SHADOW_DISK), wide); + } + } + } // Fade out towards the edge of the area the map covers. - return mix(lit, 1.0, fade); + return mix(lit, 1.0, smoothstep(0.85, 1.0, edge)); } // A low sun casts long shadows, and the sky, which it no longer outshines, fills them in: below about 20 diff --git a/src/main/resources/assets/etna/shaders/include/terrain_water.glsl b/src/main/resources/assets/etna/shaders/include/terrain_water.glsl index 61b76d4..d25b583 100644 --- a/src/main/resources/assets/etna/shaders/include/terrain_water.glsl +++ b/src/main/resources/assets/etna/shaders/include/terrain_water.glsl @@ -447,22 +447,23 @@ vec3 etnaUnjitter() { // Only the classic translucent pass blends what this writes into alpha. With improved transparency the // water is drawn without Etna's block and keeps its look from before. -// Fresnel reflectance at the boundary between two clear media. n: the index of refraction of the far -// side over that of the near side (1 / 1.333 looking from the water up into the air). Returns 1 past -// the critical angle: total internal reflection. -// Ported from Photon Shaders by SixthSurge (include/lighting/bsdf.glsl, fresnel_dielectric_n), Photon -// Shaders License, see CREDITS.md and LICENSE-PHOTON.txt. +// Fresnel reflectance of a smooth boundary between two clear media, for unpolarised light: the average of +// Fresnel's equations for the two polarisations, in the form Cook and Torrance give ("A Reflectance Model +// for Computer Graphics", 1982; also Walter et al., "Microfacet Models for Refraction through Rough +// Surfaces", 2007, eq. 22). cosTheta: the cosine between the ray and the surface's normal; n: the index of +// refraction of the far side over that of the near side (1 / 1.333 looking from the water up into the +// air). Where g^2 = n^2 - 1 + cos^2 falls below 0 no ray leaves (past the critical angle): total internal +// reflection, 1. float fresnelDielectric(float cosTheta, float n) { - float gSq = n * n + cosTheta * cosTheta - 1.0; - if (gSq < 0.0) { + float c = min(abs(cosTheta), 1.0); + float g2 = n * n - 1.0 + c * c; + if (g2 <= 0.0) { return 1.0; } - float g = sqrt(gSq); - float a = g - cosTheta; - float b = g + cosTheta; - float x = (b * cosTheta - 1.0) / max(a * cosTheta + 1.0, 1e-4); - float ab = a / max(b, 1e-6); - return clamp(0.5 * ab * ab * (1.0 + x * x), 0.0, 1.0); + float g = sqrt(g2); + float a = (g - c) / max(g + c, 1e-6); + float b = (c * (g + c) - 1.0) / max(c * (g - c) + 1.0, 1e-6); + return clamp(0.5 * a * a * (1.0 + b * b), 0.0, 1.0); } // A colour and alpha for the translucent blend, which can't take a colour above 1: alpha grows (the From 3109fe87df4111133f0dd393077baa27645ca070 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 02:05:18 +0000 Subject: [PATCH 05/24] Render scale, with Etna's own anti-aliasing and upscaler - World Resolution (50 to 100%): the level is drawn into a smaller target of Etna's (GameRenderer.mainRenderTarget() hands it out during the level), the scaled world chain runs on it, and Etna's new anti-aliasing rebuilds the full-size picture into Minecraft's own. Hand, screen effects, bloom and the HUD stay at full size. Texture detail follows the screen's pixels. - Etna's own temporal anti-aliasing replaces the Photon port: this frame rebuilt from the 3x3 level pixels with jitter-aware Gaussian weights, the history's weight kept in alpha, nearest-depth reprojection, Catmull-Rom history, YCoCg variance clipping along the line to this frame's colour, and a 1 / (1 + luma) weighted blend. - Jitter: 8 Halton steps at full size, 8 / scale^2 rounded up to a power of two below it. The terrain reads the current jitter from its block. - Light shafts: the 2x2 order comes from Bayer's ordered-dither matrix. - Fixed-size post targets are written as an explicit offset size, so a size equal to the chain's own is never mistaken for "no size". - Benchmark step "world resolution 75%"; docs and translations. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01F7MajtknSApr1T68EGCPtx --- docs/benchmark.md | 2 +- docs/settings.md | 5 +- .../java/it/cosimo/etna/EtnaBenchmark.java | 7 +- src/main/java/it/cosimo/etna/EtnaConfig.java | 12 +- src/main/java/it/cosimo/etna/EtnaFrame.java | 154 +++++++- .../java/it/cosimo/etna/EtnaMaterials.java | 8 +- src/main/java/it/cosimo/etna/EtnaMotes.java | 5 +- src/main/java/it/cosimo/etna/EtnaScale.java | 259 +++++++++++++ .../cosimo/etna/gui/EtnaSettingsScreen.java | 4 + .../cosimo/etna/mixin/GameRendererMixin.java | 41 +- .../it/cosimo/etna/mixin/PostChainMixin.java | 33 +- .../it/cosimo/etna/mixin/PostPassMixin.java | 9 +- .../resources/assets/etna/lang/en_us.json | 4 +- .../resources/assets/etna/lang/it_it.json | 4 +- .../assets/etna/post_effect/etna.json | 69 ++-- .../assets/etna/post_effect/etna_world.json | 8 +- .../etna/post_effect/etna_world_plain.json | 8 +- .../etna/post_effect/etna_world_scaled.json | 225 +++++++++++ .../assets/etna/shaders/core/terrain.fsh | 17 +- .../etna/shaders/include/terrain_ubo.glsl | 7 +- .../etna/shaders/include/terrain_water.glsl | 12 +- .../etna/shaders/post/bloom_prefilter.fsh | 14 +- .../assets/etna/shaders/post/copy_dither.fsh | 5 +- .../assets/etna/shaders/post/shafts.fsh | 12 +- .../assets/etna/shaders/post/taa.fsh | 361 ++++++++++-------- tools/gen_post_effects.py | 56 ++- tools/validate_shaders.py | 4 + 27 files changed, 1055 insertions(+), 290 deletions(-) create mode 100644 src/main/java/it/cosimo/etna/EtnaScale.java create mode 100644 src/main/resources/assets/etna/post_effect/etna_world_scaled.json diff --git a/docs/benchmark.md b/docs/benchmark.md index 7fb34ab..5113505 100644 --- a/docs/benchmark.md +++ b/docs/benchmark.md @@ -19,7 +19,7 @@ Both tools are on the **General** page of Options > Etna Shaders... * **Performance log**: set it to Summary, and every 5 seconds a line goes to `logs/latest.log` with the frame rate, the frame time and where it goes, on the CPU and on the GPU (each pass of Etna timed on one frame in eight), and the draw calls. -* **Run benchmark**: about 130 seconds, hands off, inside a world. Etna measures itself off, as set, and with each +* **Run benchmark**: about 135 seconds, hands off, inside a world. Etna measures itself off, as set, and with each costly feature off in turn, then writes a table of what each one costs to `logs/latest.log` and puts every setting back as it was. The sun and the weather hold still while it runs, so every step sees the same shadows (the rain the server sent comes back when it ends). If the scene still changes (chunks loading, mobs walking diff --git a/docs/settings.md b/docs/settings.md index a92c99e..488178c 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -22,9 +22,10 @@ Start here. Quality sets how much work Etna does, Look sets the colours and mood * **Quality**: Low: no shadows or anti-aliasing, the fastest. Medium: shadows close by. High: the recommended balance. Ultra: sharp shadows far away. * **Look**: Natural keeps Minecraft's own colours under real light. Soft is gentler and hazier, Vivid more saturated, Cinematic warmer with more contrast and glow. * **Visibility**: How well you see in the dark, without making bright places brighter: caves get more of their faint light (with Cave Light on), sun and moon shadows get lighter, your eyes open up further in dark places (with Eye Adaptation on) and the darkest tones are lifted. Bright skies, snow and sunlit ground stay as they are. 0% (Off) is Etna 0.7.0's darkness. It stays where you put it when you change the Look. +* **World Resolution**: How many pixels the world is drawn at, as a share of the screen's width and height. Below 100% Etna draws the world smaller and its anti-aliasing builds the full picture back up over a few frames, the way DLSS and FSR do: much faster, a little softer, mostly in motion. 75% draws about half the pixels. The hand, the HUD and menus stay sharp. Needs anti-aliasing, which every Quality but Low has. * **Resolution** *(macOS only)*: Half draws a quarter of the pixels and lets macOS scale the picture up: much faster, but blurry. Applies after restarting the game, and only when Etna is on as the game starts. * **Performance log**: Summary writes a line to latest.log every few seconds while you play: frame rate, frame time and where it goes, on the CPU and on the GPU (timed on one frame in eight, so the frame rate stays the normal one). Off writes nothing. -* **Run benchmark**: Takes about 130 seconds, in a world: Etna tries its settings one at a time, writes what each one costs to latest.log, then puts everything back as it was. Stand still and don't move the mouse: a step disturbed by a camera move or a stutter is measured again, which can add up to about 20 seconds. Turn any frame-rate limiter mod and VSync off first. +* **Run benchmark**: Takes about 135 seconds, in a world: Etna tries its settings one at a time, writes what each one costs to latest.log, then puts everything back as it was. Stand still and don't move the mouse: a step disturbed by a camera move or a stutter is measured again, which can add up to about 20 seconds. Turn any frame-rate limiter mod and VSync off first. ## Mood @@ -178,6 +179,8 @@ with the game closed. * `toggleKey`: the key code of the on/off key, F7 by default. * `halfResolution` (`true`/`false`): macOS only, applies after a restart, and only when Etna is on as the game starts. +* `renderScale` (`0.5` to `1`): the share of the screen's width and height the world is drawn at, with + anti-aliasing on; `1` (the default) draws it at full size. * `sunPathTilt`: 0 to 45 degrees; 0 is vanilla's path straight overhead. * `visibility`: 0 to 1; 0 is 0.7.0's darkness. * `mood` (`natural`, `clear_day`, `trailer`, `storybook`, `old_kingdom`, `ink`, `lifelike`, `enchanted`, `mystic`, diff --git a/src/main/java/it/cosimo/etna/EtnaBenchmark.java b/src/main/java/it/cosimo/etna/EtnaBenchmark.java index 57b4f2e..165ed69 100644 --- a/src/main/java/it/cosimo/etna/EtnaBenchmark.java +++ b/src/main/java/it/cosimo/etna/EtnaBenchmark.java @@ -30,7 +30,7 @@ * A step whose camera moves or turns, or that hits a frame over 50 ms (a pipeline compile, chunks * loading, a garbage collection), starts measuring again half a second later, up to three tries; only a * few of those in a run, so it stays within about two minutes. The table marks the steps that stayed - * disturbed. Twenty steps take about 130 seconds. The run moves on from the start of each frame; a + * disturbed. Twenty-one steps take about 135 seconds. The run moves on from the start of each frame; a * client-tick listener only watches Etna's key. * * The sun holds still for the run, where Etna and the sky read it: in the two minutes a run takes it @@ -121,6 +121,9 @@ private record Step(String name, Role role, Runnable change, Predicate base -> base.shadows), new Step(AA_OFF, Role.OTHER, () -> EtnaConfig.antiAliasing = false, base -> base.antiAliasing), + // The world drawn at 75% of the width and height, built back up by the anti-aliasing (EtnaScale). + new Step("world resolution 75%", Role.OTHER, () -> EtnaConfig.renderScale = 0.75F, + base -> base.antiAliasing && base.renderScale != 0.75F), new Step("surface detail+shine off", Role.OTHER, () -> { EtnaConfig.surfaceDetail.set(0.0F); EtnaConfig.shine.set(0.0F); @@ -913,6 +916,7 @@ private static final class Snapshot { final float shadowDistance = EtnaConfig.shadowDistance; final boolean antiAliasing = EtnaConfig.antiAliasing; final boolean shadowCulling = EtnaConfig.shadowCulling; + final float renderScale = EtnaConfig.renderScale; final float[] look = new float[EtnaConfig.LOOK.size()]; // The 0.8.0 settings outside the Look. final EtnaConfig.Mood mood = EtnaConfig.mood; @@ -953,6 +957,7 @@ void restore() { EtnaConfig.shadowDistance = this.shadowDistance; EtnaConfig.antiAliasing = this.antiAliasing; EtnaConfig.shadowCulling = this.shadowCulling; + EtnaConfig.renderScale = this.renderScale; for (int i = 0; i < this.look.length; i++) { // Exactly the old value: Setting.set would clamp, and these came from the same ranges anyway. EtnaConfig.LOOK.get(i).value = this.look[i]; diff --git a/src/main/java/it/cosimo/etna/EtnaConfig.java b/src/main/java/it/cosimo/etna/EtnaConfig.java index 0c2fed4..863dfb4 100644 --- a/src/main/java/it/cosimo/etna/EtnaConfig.java +++ b/src/main/java/it/cosimo/etna/EtnaConfig.java @@ -216,6 +216,13 @@ public static void resetSurfaces() { public static float sunPathTilt = SUN_PATH_TILT_DEFAULT; /** macOS: open the window without Retina pixel density, so the game draws a quarter of the pixels. */ public static boolean halfResolution = false; + /** + * How wide and tall the world is drawn, as a share of the screen (EtnaScale): below 1 the level is drawn at fewer + * pixels and Etna's anti-aliasing builds the full-size picture from it over several frames. Only with + * anti-aliasing on; the hand and the HUD always stay at full size. + */ + public static float renderScale = 1.0F; + public static final float RENDER_SCALE_MIN = 0.5F; /** The performance log: a line in latest.log every few seconds while in a world. */ public static Profiling profiling = Profiling.OFF; /** Skip shadow casters that can't shadow anything on screen. Only in the file, to compare with and without. */ @@ -395,6 +402,7 @@ public static void load() { return; } halfResolution = bool(props, "halfResolution", false); + renderScale = number(props, "renderScale", 1.0F, RENDER_SCALE_MIN, 1.0F); sunPathTilt = number(props, "sunPathTilt", SUN_PATH_TILT_DEFAULT, 0.0F, 45.0F); visibility.set(number(props, "visibility", VISIBILITY_DEFAULT, visibility.min, visibility.max)); // 0.8.0 MOOD @@ -453,6 +461,7 @@ public static void save() { props.setProperty("enabled", Boolean.toString(enabled)); props.setProperty("toggleKey", Integer.toString(toggleKey)); props.setProperty("halfResolution", Boolean.toString(halfResolution)); + props.setProperty("renderScale", Float.toString(renderScale)); props.setProperty("sunPathTilt", Float.toString(sunPathTilt)); props.setProperty("visibility", Float.toString(visibility.value)); // 0.8.0 MOOD @@ -497,7 +506,8 @@ public static void save() { + " shadowDistance and antiAliasing are only read back with quality=custom). profile: natural | soft | vivid" + " | cinematic | custom (the look values are only read back with profile=custom; eyeAdaptation: 0 (off) .. 1)." + " toggleKey: key code, F7 by" - + " default. halfResolution: macOS only, applies after a restart. sunPathTilt: 0 to 45, how many degrees the" + + " default. halfResolution: macOS only, applies after a restart. renderScale: 0.5 .. 1, the share of the screen's" + + " width and height the world is drawn at (with anti-aliasing on; 1: full size). sunPathTilt: 0 to 45, how many degrees the" + " path of the sun and the moon leans to the south (0 is vanilla's path). visibility: 0 .. 1, how much better caves," + " nights and shade read (0: as in 0.7.0), kept whatever the profile. profiling: off | summary (a performance line in" + " latest.log every few seconds). shadowCulling: true | false (false draws every shadow caster, to compare)." diff --git a/src/main/java/it/cosimo/etna/EtnaFrame.java b/src/main/java/it/cosimo/etna/EtnaFrame.java index fef6f2c..6928e5b 100644 --- a/src/main/java/it/cosimo/etna/EtnaFrame.java +++ b/src/main/java/it/cosimo/etna/EtnaFrame.java @@ -1,9 +1,12 @@ package it.cosimo.etna; import java.lang.ref.WeakReference; +import java.util.Set; +import com.mojang.blaze3d.framegraph.FrameGraphBuilder; import com.mojang.blaze3d.pipeline.RenderTarget; import com.mojang.blaze3d.resource.GraphicsResourceAllocator; +import com.mojang.blaze3d.resource.ResourceHandle; import com.mojang.blaze3d.systems.RenderSystem; import net.minecraft.client.Minecraft; import net.minecraft.client.multiplayer.ClientLevel; @@ -19,8 +22,8 @@ * Per-frame camera bookkeeping for the passes that look back at earlier frames, and the world pass. * * Temporal anti-aliasing moves the level's projection by a different fraction of a pixel every frame - * (an 8-step Halton sequence), so over a few frames every pixel sees its whole area. The world post - * chain (light shafts, ambient occlusion, the air and the mist, and anti-aliasing) then runs right after + * (a Halton sequence of 8 steps, more with the world drawn smaller), so over a few frames every pixel sees + * its whole area. The world post chain (light shafts, ambient occlusion, the air and the mist, and anti-aliasing) then runs right after * the level, before the hand is drawn: the hand is neither jittered nor blended with old frames, so it * never smears. * @@ -33,13 +36,21 @@ public final class EtnaFrame { public static final Identifier WORLD = Identifier.fromNamespaceAndPath(EtnaClient.MOD_ID, "etna_world"); public static final Identifier WORLD_PLAIN = Identifier.fromNamespaceAndPath(EtnaClient.MOD_ID, "etna_world_plain"); + /** + * The world chain for a world drawn smaller than the screen (EtnaScale): the same passes on the smaller picture, + * with the anti-aliasing's history at full size and its last pass writing Minecraft's own picture, "etna:screen". + */ + public static final Identifier WORLD_SCALED = Identifier.fromNamespaceAndPath(EtnaClient.MOD_ID, "etna_world_scaled"); + private static final Set SCALED_TARGETS = Set.of(PostChain.MAIN_TARGET_ID, EtnaScale.SCREEN); /** - * The jitter is Halton(2, 3) over these steps, step = frame % JITTER_STEPS + 1. terrain_water.glsl - * (ETNA_JITTER) rebuilds the same sequence from EtnaLook2.z (frame % 4096) to hold the water's glints - * still: change the two together. + * The jitter is Halton(2, 3) over these steps at full size, step = frame % steps + 1 (jitterSteps). The + * terrain reads this frame's jitter from its block (EtnaSurface2.zw, EtnaMaterials) to hold the water's glints + * still. */ private static final int JITTER_STEPS = 8; + /** The most steps with the world drawn smaller (from half its width down; see jitterSteps). */ + private static final int JITTER_STEPS_MAX = 32; /** A camera jump longer than this (teleport, respawn) starts the history over. */ private static final double MAX_CAMERA_STEP = 16.0; @@ -62,11 +73,16 @@ public final class EtnaFrame { private static float motionZ; private static int width; private static int height; + /** The screen's size that frame (EtnaScale): the history is at that size. */ + private static int screenWidth; + private static int screenHeight; /** The level of the last frame drawn, held weakly: after leaving a world the menus must not keep it alive. */ private static WeakReference<@Nullable ClientLevel> level = new WeakReference<>(null); private static boolean worldFailed; private static boolean plainFailed; private static boolean historyHeld; + /** Which world chain holds the anti-aliasing history: the scaled one or the full-size one. */ + private static boolean historyScaled; /** * The anti-aliasing history is two textures used in turn (PostChainMixin): when this is set, last * frame's result is in the one named "history1", so the names are swapped for this frame. @@ -95,7 +111,8 @@ public static Matrix4f beginLevel(Matrix4f projection, CameraRenderState camera, double dz = pos.z - cameraZ; ClientLevel current = mc.level; boolean sameLevel = current == level.get(); - boolean sameView = lastWorldFrame == frame - 1 && targetWidth == width && targetHeight == height && sameLevel + boolean sameView = lastWorldFrame == frame - 1 && targetWidth == width && targetHeight == height + && EtnaScale.screenWidth() == screenWidth && EtnaScale.screenHeight() == screenHeight && sameLevel && dx * dx + dy * dy + dz * dz < MAX_CAMERA_STEP * MAX_CAMERA_STEP; historyValid = temporal && sameView; motionX = (float) dx; @@ -106,6 +123,8 @@ public static Matrix4f beginLevel(Matrix4f projection, CameraRenderState camera, cameraZ = pos.z; width = targetWidth; height = targetHeight; + screenWidth = EtnaScale.screenWidth(); + screenHeight = EtnaScale.screenHeight(); if (!sameLevel) { level = new WeakReference<>(current); } @@ -120,7 +139,7 @@ public static Matrix4f beginLevel(Matrix4f projection, CameraRenderState camera, jitterY = 0.0F; return projection; } - int step = (int) (frame % JITTER_STEPS) + 1; + int step = (int) (frame % jitterSteps(targetWidth)) + 1; jitterX = halton(step, 2) - 0.5F; jitterY = halton(step, 3) - 0.5F; // Shift in clip space: x' = x + offset * w moves the whole picture by the offset in NDC. @@ -139,18 +158,22 @@ public static void processWorld(RenderTarget mainTarget, GraphicsResourceAllocat if (!temporal) { // Anti-aliasing (or Etna) was turned off: give back the screen-size history it kept. if (historyHeld) { - historyHeld = false; - historySwapped = false; - PostChain chain = mc.getShaderManager().getPostChain(WORLD, LevelTargetBundle.MAIN_TARGETS); - if (chain != null) { - chain.closePersistentTargets(); - } + releaseHistory(mc); } if (EtnaClient.activeEffect() != null && EtnaConfig.worldPassActive() && !plainFailed) { processPlain(mc, mainTarget, allocator); } return; } + RenderTarget scaled = EtnaScale.scaledTarget(); + if (historyHeld && historyScaled != (scaled != null)) { + // The world changed size: the other chain's history no longer fits, and beginLevel already started over. + releaseHistory(mc); + } + if (scaled != null) { + processScaled(mc, mainTarget, scaled, allocator); + return; + } PostChain chain = mc.getShaderManager().getPostChain(WORLD, LevelTargetBundle.MAIN_TARGETS); if (chain == null) { fail("the world pass did not load", null); @@ -160,15 +183,88 @@ public static void processWorld(RenderTarget mainTarget, GraphicsResourceAllocat outlineFrame = frame; try { chain.process(mainTarget, allocator); - lastWorldFrame = frame; - historyHeld = true; - // This frame's result went into the texture the next frame must read as its history. - historySwapped = !historySwapped; + worldDone(false); } catch (RuntimeException e) { fail("the world pass failed", e); } } + /** + * The world chain on a world drawn smaller than the screen (EtnaScale): its passes run on the smaller picture, + * and the anti-aliasing builds the full-size one into Minecraft's own (its last pass writes "etna:screen"). + * The entity outlines were drawn at the smaller size too, so they are blended in first, as on the full-size path. + */ + private static void processScaled(Minecraft mc, RenderTarget screen, RenderTarget scaled, GraphicsResourceAllocator allocator) { + PostChain chain = mc.getShaderManager().getPostChain(WORLD_SCALED, SCALED_TARGETS); + if (chain == null) { + // Without it nothing builds the full-size picture; the next frame draws the world at full size. + EtnaScale.fail("the scaled world pass did not load", null); + EtnaScale.clearScreen(screen); + return; + } + mc.levelRenderer.blitEntityOutline(); + outlineFrame = frame; + try { + FrameGraphBuilder graph = new FrameGraphBuilder(); + ScaledTargets targets = new ScaledTargets(graph.importExternal("main", scaled), graph.importExternal("screen", screen)); + chain.addToFrame(graph, scaled.width, scaled.height, targets); + graph.execute(allocator); + worldDone(true); + } catch (RuntimeException e) { + EtnaScale.fail("the scaled world pass failed", e); + EtnaScale.clearScreen(screen); + } + } + + private static void worldDone(boolean scaled) { + lastWorldFrame = frame; + historyHeld = true; + historyScaled = scaled; + // This frame's result went into the texture the next frame must read as its history. + historySwapped = !historySwapped; + } + + /** Gives back the screen-size history the anti-aliasing kept, in whichever world chain holds it. */ + private static void releaseHistory(Minecraft mc) { + historyHeld = false; + historySwapped = false; + PostChain chain = historyScaled ? mc.getShaderManager().getPostChain(WORLD_SCALED, SCALED_TARGETS) + : mc.getShaderManager().getPostChain(WORLD, LevelTargetBundle.MAIN_TARGETS); + if (chain != null) { + chain.closePersistentTargets(); + } + } + + /** Minecraft's picture under its two names in the scaled world chain: the smaller one as main, the screen's own. */ + private static final class ScaledTargets implements PostChain.TargetBundle { + private ResourceHandle main; + private ResourceHandle screen; + + ScaledTargets(ResourceHandle main, ResourceHandle screen) { + this.main = main; + this.screen = screen; + } + + @Override + public void replace(Identifier id, ResourceHandle handle) { + if (id.equals(PostChain.MAIN_TARGET_ID)) { + this.main = handle; + } else if (id.equals(EtnaScale.SCREEN)) { + this.screen = handle; + } else { + throw new IllegalArgumentException("No target with id " + id); + } + } + + @Override + public @Nullable ResourceHandle get(Identifier id) { + if (id.equals(PostChain.MAIN_TARGET_ID)) { + return this.main; + } + return id.equals(EtnaScale.SCREEN) ? this.screen : null; + } + } + /** * The plain world chain, without anti-aliasing. The entity outlines stay vanilla's to blend after it: nothing * is jittered, so they already line up. @@ -208,6 +304,7 @@ public static void resourcesReloaded() { worldFailed = false; plainFailed = false; historyHeld = false; + EtnaScale.resourcesReloaded(); EtnaSky.resourcesReloaded(); EtnaClouds.resourcesReloaded(); EtnaWeather.resourcesReloaded(); @@ -228,6 +325,11 @@ public static boolean temporal() { return temporal; } + /** False once the anti-aliased world chain has failed this session: nothing could build a smaller world back up. */ + public static boolean worldPassWorks() { + return !worldFailed; + } + public static long frame() { return frame; } @@ -276,7 +378,23 @@ public static float jitterY() { return jitterY; } - /** Also tabulated in terrain_water.glsl (ETNA_JITTER); see JITTER_STEPS. */ + /** + * Jitter steps for a level drawn targetWidth pixels wide. With the world drawn smaller than the screen + * (EtnaScale) each step's samples are spread over fewer level pixels than there are screen pixels, so it takes + * more steps for every screen pixel to be seen about as often as at full size: 8 / scale^2 (the camera jitter + * guidance of AMD's FidelityFX Super Resolution 2), rounded up to a power of two so frame % 4096 stays a + * whole number of cycles. 16 at 75%, 32 at half size. + */ + private static int jitterSteps(int targetWidth) { + float scale = (float) targetWidth / Math.max(EtnaScale.screenWidth(), 1); + if (scale >= 0.999F) { + return JITTER_STEPS; + } + int wanted = (int) Math.ceil(JITTER_STEPS / (scale * scale)); + return Math.min(Integer.highestOneBit(wanted - 1) << 1, JITTER_STEPS_MAX); + } + + /** Halton's radical inverse of index in base. */ private static float halton(int index, int base) { float f = 1.0F; float result = 0.0F; diff --git a/src/main/java/it/cosimo/etna/EtnaMaterials.java b/src/main/java/it/cosimo/etna/EtnaMaterials.java index 44f5a5a..ca9691d 100644 --- a/src/main/java/it/cosimo/etna/EtnaMaterials.java +++ b/src/main/java/it/cosimo/etna/EtnaMaterials.java @@ -121,12 +121,16 @@ static int materialOf(String name) { /** * The EtnaSurface and EtnaSurface2 vec4s of the terrain block, bytes 512 to 543: the materials, puddles, * sparkles and glowing flowers settings, and how much the specks of glowing blocks glow (the glowing blocks - * setting: unlike the light-15 glow of terrain_dark.glsl, the specks don't need smooth lighting). + * setting: unlike the light-15 glow of terrain_dark.glsl, the specks don't need smooth lighting), and the share of + * the screen's width the level is drawn at this frame (EtnaScale), which the block textures' detail follows, + * and this frame's anti-aliasing jitter in the level's pixels (EtnaFrame; 0 without anti-aliasing), which the + * water's glints are held still against (terrain_water.glsl, etnaUnjitter). */ public static void writeTerrain(Std140Builder builder) { builder.putVec4(new Vector4f(EtnaConfig.materials.value, EtnaConfig.puddles.value, EtnaConfig.sparkles.value, EtnaConfig.glowingFlowers.value)) - .putVec4(new Vector4f(EtnaConfig.emission.value, 0.0F, 0.0F, 0.0F)); + .putVec4(new Vector4f(EtnaConfig.emission.value, (float) EtnaScale.levelWidth() / EtnaScale.screenWidth(), + EtnaFrame.jitterX(), EtnaFrame.jitterY())); } /** A block's model that puts in its material's tag while Etna is on. */ diff --git a/src/main/java/it/cosimo/etna/EtnaMotes.java b/src/main/java/it/cosimo/etna/EtnaMotes.java index 48304fe..4a9d1af 100644 --- a/src/main/java/it/cosimo/etna/EtnaMotes.java +++ b/src/main/java/it/cosimo/etna/EtnaMotes.java @@ -10,7 +10,6 @@ import java.util.SplittableRandom; import com.mojang.blaze3d.buffers.Std140Builder; -import com.mojang.blaze3d.pipeline.RenderTarget; import com.mojang.blaze3d.systems.RenderSystem; import com.mojang.renderpearl.api.buffers.GpuBuffer; import net.minecraft.client.Minecraft; @@ -465,14 +464,14 @@ public static void upload(LevelRenderState state) { for (int i = 0; i < TILES_X * TILES_Y; i++) { bytes.putInt(TILES_OFFSET + i * 4, 0); } - RenderTarget screen = Minecraft.getInstance().gameRenderer.mainRenderTarget(); Vec3 camera = state.cameraRenderState.pos; EtnaFrame.viewProjection(viewProjection); for (Mote mote : motes) { if (drawn == MAX) { break; } - if (project(bytes, mote, camera, screen.width, screen.height)) { + // The motes are drawn on the full-size picture, also when the world is drawn smaller (EtnaScale). + if (project(bytes, mote, camera, EtnaScale.screenWidth(), EtnaScale.screenHeight())) { drawn++; } } diff --git a/src/main/java/it/cosimo/etna/EtnaScale.java b/src/main/java/it/cosimo/etna/EtnaScale.java new file mode 100644 index 0000000..7f2db14 --- /dev/null +++ b/src/main/java/it/cosimo/etna/EtnaScale.java @@ -0,0 +1,259 @@ +package it.cosimo.etna; + +import java.util.Map; + +import com.mojang.blaze3d.framegraph.FramePass; +import com.mojang.blaze3d.pipeline.RenderTarget; +import com.mojang.blaze3d.pipeline.TextureTarget; +import com.mojang.blaze3d.resource.ResourceHandle; +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.renderpearl.api.GpuFormat; +import com.mojang.renderpearl.api.textures.GpuTextureView; +import net.minecraft.client.renderer.PostChain; +import net.minecraft.client.renderer.PostPass; +import net.minecraft.resources.Identifier; +import org.joml.Vector4f; +import org.jspecify.annotations.Nullable; + +/** + * Render scale: the world drawn at fewer pixels than the screen has, and built back up to full size by Etna's + * anti-aliasing (temporal upsampling, post/taa.fsh). Everything Etna adds to the terrain is worked out per pixel, + * and so is most of vanilla's own drawing, so at 0.75 of the width and height the level pass shades a little over + * half the pixels. The camera's sub-pixel jitter (EtnaFrame) moves in the smaller picture's pixels, so over a few + * frames the anti-aliasing still sees every part of each screen pixel. + * + * While the level is drawn, GameRenderer.mainRenderTarget() hands out a smaller target of Etna's own + * (GameRendererMixin), so LevelRenderer builds its whole frame on it, from the sky and the terrain to the + * particles, the weather and the translucent layers, and every size it derives comes out the same. The world + * chain then runs on that target and its last pass writes the full-size picture into Minecraft's own (EtnaFrame, + * the "etna:screen" target). The hand, the screen effects and the HUD are drawn after that, at full size and never + * jittered. The final chain's passes that read main's depth (the bloom's distance) get the level's depth from the + * smaller target instead (LEVEL_DEPTH): main's own depth holds only the hand then. + * + * Only with Etna's anti-aliasing running: without it nothing would build the full-size picture. + */ +public final class EtnaScale { + /** The world chain's own name for Minecraft's full-size picture while the world is drawn smaller. */ + public static final Identifier SCREEN = Identifier.fromNamespaceAndPath(EtnaClient.MOD_ID, "screen"); + /** + * A target in an Etna chain this many texels wide or tall is made the size of the full screen (PostChainMixin): + * the anti-aliasing history, which the world chain keeps at full size while it runs on the smaller picture. + */ + public static final int SCREEN_SIZED = 16384; + + /** + * A target in an Etna chain whose width or height is this much more than a number is that number of texels + * for a 1920x1080 screen, scaled to the size the chain runs at (PostChainMixin): the bloom levels, and the half-size + * light shafts and water reflections. A target with no size at all is the chain's own size, as in vanilla. + */ + public static final int REFERENCE_SIZED = 20000; + + /** The bloom's input for main's own depth, which LevelDepth leaves alone (gen_post_effects.py knows this name). */ + public static final String HAND_DEPTH = "HandDepth"; + + private static final Vector4f BLACK = new Vector4f(0.0F, 0.0F, 0.0F, 1.0F); + + private static @Nullable TextureTarget target; + /** This frame's level is being drawn into the smaller target. */ + private static boolean drawing; + /** This frame's world was drawn smaller (from the start of the level to the end of the frame). */ + private static boolean scaledFrame; + private static int screenWidth = 1; + private static int screenHeight = 1; + private static int levelWidth = 1; + private static int levelHeight = 1; + private static boolean failed; + + private EtnaScale() { + } + + /** The scale the settings ask for, clamped; 1 when it can't apply (Etna or its anti-aliasing off). */ + public static float wanted() { + if (failed || !EtnaConfig.temporalActive() || EtnaClient.activeEffect() == null || !EtnaFrame.worldPassWorks()) { + return 1.0F; + } + return Math.clamp(EtnaConfig.renderScale, EtnaConfig.RENDER_SCALE_MIN, 1.0F); + } + + /** + * GameRenderer.renderLevel, before anything of the level is drawn: decides this frame's size and gets the + * smaller target ready (made or resized), or frees it when the world is drawn at full size again. + * LevelRenderer clears the smaller target itself, as it clears whichever target mainRenderTarget() gives it. + */ + public static void beginLevel(RenderTarget main) { + screenWidth = Math.max(main.width, 1); + screenHeight = Math.max(main.height, 1); + levelWidth = screenWidth; + levelHeight = screenHeight; + drawing = false; + scaledFrame = false; + float scale = wanted(); + int width = Math.max(1, Math.round(screenWidth * scale)); + int height = Math.max(1, Math.round(screenHeight * scale)); + if (width >= screenWidth && height >= screenHeight) { + release(); + return; + } + try { + TextureTarget scaled = target; + if (scaled == null) { + // The level's pipelines are built for Minecraft's own picture: the same formats. + GpuFormat depthFormat = main.getDepthTexture() != null ? main.getDepthTexture().getFormat() : GpuFormat.D32_FLOAT; + scaled = new TextureTarget("Etna level", width, height, main.getColorTexture().getFormat(), depthFormat); + target = scaled; + } else if (scaled.width != width || scaled.height != height) { + scaled.resize(width, height); + } + // Minecraft's own depth then holds only the hand, which the bloom tells apart from the level by it + // (bloom_prefilter.fsh): clear what the last frame left there (its GUI's depth, a full-size level). + if (main.getDepthTexture() != null) { + RenderSystem.getDevice().createCommandEncoder().clearDepthTexture(main.getDepthTexture(), 0.0); + } + levelWidth = width; + levelHeight = height; + drawing = true; + scaledFrame = true; + } catch (RuntimeException e) { + fail("could not make the smaller target", e); + } + } + + /** + * GameRenderer.render, before anything is drawn: no frame starts with the smaller target handed out, and a + * frame without a level (menus, after leaving a world) frees it. + */ + public static void beginFrame(boolean level) { + drawing = false; + scaledFrame = false; + if (!level) { + release(); + } + } + + /** + * The world chain could not build the full-size picture from the smaller one: Minecraft's own still holds the + * last frame's finished picture, GUI and all. Black is less wrong for the one frame before the world is drawn at + * full size again. + */ + public static void clearScreen(RenderTarget main) { + try { + if (main.getDepthTexture() != null) { + RenderSystem.getDevice().createCommandEncoder().clearColorAndDepthTextures(main.getColorTexture(), BLACK, + main.getDepthTexture(), 0.0); + } + } catch (RuntimeException e) { + EtnaClient.LOGGER.warn("Etna: could not clear the screen after the scaled world pass failed", e); + } + } + + /** GameRenderer.renderLevel, once the world chain has written the full-size picture: the hand comes next. */ + public static void endLevel() { + drawing = false; + } + + /** What GameRenderer.mainRenderTarget() returns: the smaller target while this frame's level is drawn. */ + public static RenderTarget levelTarget(RenderTarget main) { + TextureTarget scaled = target; + return drawing && scaled != null ? scaled : main; + } + + /** The smaller target while it holds this frame's level, else null. */ + public static @Nullable RenderTarget scaledTarget() { + return scaledFrame ? target : null; + } + + public static boolean scaledFrame() { + return scaledFrame; + } + + /** The size the level is drawn at this frame, in pixels. */ + public static int levelWidth() { + return levelWidth; + } + + public static int levelHeight() { + return levelHeight; + } + + /** The screen's size (Minecraft's own picture), in pixels. */ + public static int screenWidth() { + return screenWidth; + } + + public static int screenHeight() { + return screenHeight; + } + + /** The world chain failed on the smaller picture: the world is drawn at full size for the rest of the session. */ + public static void fail(String why, @Nullable Throwable cause) { + failed = true; + drawing = false; + scaledFrame = false; + if (cause != null) { + EtnaClient.LOGGER.error("Etna: render scale is off for this session, {}", why, cause); + } else { + EtnaClient.LOGGER.error("Etna: render scale is off for this session, {}", why); + } + } + + /** Resources were reloaded: the world chains were rebuilt, so a scaled chain that failed gets another try. */ + public static void resourcesReloaded() { + failed = false; + } + + private static void release() { + TextureTarget scaled = target; + if (scaled != null) { + target = null; + scaled.destroyBuffers(); + } + } + + /** + * Stands in for a pass input that reads main's depth (PostPassMixin, Etna's passes only): on a frame whose world + * was drawn smaller, the level's depth from the smaller target, else main's own. The frame still orders the + * pass after whatever wrote main, as the input it replaces did. + */ + public record LevelDepth(PostPass.Input original) implements PostPass.Input { + @Override + public void addToPass(FramePass pass, Map> targets) { + this.original.addToPass(pass, targets); + } + + @Override + public void cleanup(Map> targets) { + this.original.cleanup(targets); + } + + @Override + public GpuTextureView texture(Map> targets) { + RenderTarget scaled = scaledTarget(); + if (scaled != null) { + GpuTextureView depth = scaled.getDepthTextureView(); + if (depth != null) { + return depth; + } + } + return this.original.texture(targets); + } + + @Override + public String samplerName() { + return this.original.samplerName(); + } + + @Override + public boolean bilinear() { + return this.original.bilinear(); + } + + /** + * Whether an input reads main's depth, the inputs LevelDepth stands in for. Not "HandDepth", which the bloom + * reads to find the hand in main's own depth. + */ + public static boolean readsMainDepth(PostPass.Input input) { + return input instanceof PostPass.TargetInput target && target.depthBuffer() && target.targetId().equals(PostChain.MAIN_TARGET_ID) + && !target.samplerName().equals(HAND_DEPTH); + } + } +} diff --git a/src/main/java/it/cosimo/etna/gui/EtnaSettingsScreen.java b/src/main/java/it/cosimo/etna/gui/EtnaSettingsScreen.java index c9ba99b..e278eda 100644 --- a/src/main/java/it/cosimo/etna/gui/EtnaSettingsScreen.java +++ b/src/main/java/it/cosimo/etna/gui/EtnaSettingsScreen.java @@ -166,6 +166,10 @@ private void generalPage() { // Not part of the look: moving it keeps the look as it is. this.add(new Slider("visibility", EtnaConfig.visibility.min, EtnaConfig.visibility.max, EtnaConfig.visibility.value, EtnaSettingsScreen::percentOrOff, v -> EtnaConfig.visibility.set((float) v)), "visibility"); + // Not part of the look either: how many pixels the world is drawn at (EtnaScale), in steps of 5%. + this.add(new Slider("renderScale", EtnaConfig.RENDER_SCALE_MIN, 1.0, EtnaConfig.renderScale, + v -> Component.literal(Math.round(v * 20.0) * 5 + "%"), v -> EtnaConfig.renderScale = Math.round((float) v * 20.0F) / 20.0F), + "renderScale"); if (MacosUtil.IS_MACOS) { this.cycle("resolution", () -> Component.translatable(EtnaConfig.halfResolution ? "etna.options.resolution.half" : "etna.options.resolution.full"), diff --git a/src/main/java/it/cosimo/etna/mixin/GameRendererMixin.java b/src/main/java/it/cosimo/etna/mixin/GameRendererMixin.java index 4ab9a7d..f067bd9 100644 --- a/src/main/java/it/cosimo/etna/mixin/GameRendererMixin.java +++ b/src/main/java/it/cosimo/etna/mixin/GameRendererMixin.java @@ -7,7 +7,9 @@ import it.cosimo.etna.EtnaClient; import it.cosimo.etna.EtnaFrame; import it.cosimo.etna.EtnaGpuTimer; +import it.cosimo.etna.EtnaScale; import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; import net.minecraft.client.renderer.GameRenderer; import net.minecraft.client.renderer.LevelRenderer; import net.minecraft.client.renderer.state.GameRenderState; @@ -22,6 +24,7 @@ import org.spongepowered.asm.mixin.injection.ModifyArg; import org.spongepowered.asm.mixin.injection.Redirect; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; /** * Hooks Etna's two post chains into the frame. @@ -33,6 +36,10 @@ * retried every frame. The world chain (ambient occlusion, anti-aliasing) runs earlier, between the * level and the hand, and the level's projection gets this frame's anti-aliasing jitter. Glowing-entity * outlines are blended in by the world pass, so vanilla's later blend is skipped on those frames. + * + * With a render scale below 1 (EtnaScale), mainRenderTarget() hands out Etna's smaller target from the start of + * the level until the world chain has written the full-size picture, so the level is drawn smaller; the hand and + * everything after it use the full-size target, which this class reads through the field. */ @Mixin(GameRenderer.class) public abstract class GameRendererMixin { @@ -60,6 +67,30 @@ public abstract class GameRendererMixin { } } + @Inject(method = "render", at = @At("HEAD")) + private void etna$frameStart(CallbackInfo ci) { + EtnaScale.beginFrame(Minecraft.getInstance().level != null); + } + + @Inject(method = "renderLevel", at = @At("HEAD")) + private void etna$levelSize(CallbackInfo ci) { + EtnaScale.beginLevel(mainRenderTarget); + } + + @Inject(method = "renderLevel", at = @At("RETURN")) + private void etna$levelEnd(CallbackInfo ci) { + // Also here in case the hand's hook below never ran: nothing after the level may draw into the smaller target. + EtnaScale.endLevel(); + } + + @Inject(method = "mainRenderTarget", at = @At("HEAD"), cancellable = true) + private void etna$levelTarget(CallbackInfoReturnable cir) { + RenderTarget level = EtnaScale.levelTarget(mainRenderTarget); + if (level != mainRenderTarget) { + cir.setReturnValue(level); + } + } + @ModifyArg( method = "renderLevel", at = @At( @@ -68,7 +99,8 @@ public abstract class GameRendererMixin { ) ) private Matrix4f etna$jitterProjection(Matrix4f projection) { - return EtnaFrame.beginLevel(projection, gameRenderState.levelRenderState.cameraRenderState, mainRenderTarget.width, mainRenderTarget.height); + // The jitter moves in the pixels the level is drawn at, which are the smaller target's with a render scale. + return EtnaFrame.beginLevel(projection, gameRenderState.levelRenderState.cameraRenderState, EtnaScale.levelWidth(), EtnaScale.levelHeight()); } @Inject( @@ -81,7 +113,12 @@ public abstract class GameRendererMixin { private void etna$worldEffects(CallbackInfo ci) { // The level is drawn: the GPU timer brackets the world pass (only while the log or the benchmark runs). EtnaGpuTimer.mark(EtnaGpuTimer.Mark.LEVEL_END); - EtnaFrame.processWorld(mainRenderTarget, resourcePool); + try { + EtnaFrame.processWorld(mainRenderTarget, resourcePool); + } finally { + // The hand and everything after it draw at full size. + EtnaScale.endLevel(); + } EtnaGpuTimer.mark(EtnaGpuTimer.Mark.WORLD_END); } diff --git a/src/main/java/it/cosimo/etna/mixin/PostChainMixin.java b/src/main/java/it/cosimo/etna/mixin/PostChainMixin.java index aeb9bc5..8929591 100644 --- a/src/main/java/it/cosimo/etna/mixin/PostChainMixin.java +++ b/src/main/java/it/cosimo/etna/mixin/PostChainMixin.java @@ -9,6 +9,7 @@ import com.mojang.renderpearl.api.pipeline.ColorTargetState; import it.cosimo.etna.EtnaClient; import it.cosimo.etna.EtnaFrame; +import it.cosimo.etna.EtnaScale; import net.minecraft.client.renderer.PostChain; import net.minecraft.client.renderer.PostChainConfig; import net.minecraft.client.renderer.PostPass; @@ -32,10 +33,12 @@ * is "graded", the colour grade's result: it is already clamped and dithered to 8 bits for Minecraft's * 8-bit picture, which is all that reads it. Every pass that writes into a 16-bit target is built for * that format; passes that write into an 8-bit one keep vanilla's. Etna's targets also get no depth - * texture (no Etna pass uses one), and fixed-size targets (the bloom levels) are sized for a 1920x1080 - * screen and scaled to the real one, width with width and height with height: the first bloom level is + * texture (no Etna pass uses one), and fixed-size targets (the bloom levels, the half-size light shafts and + * reflections; their sizes are EtnaScale.REFERENCE_SIZED plus the size) are sized for a 1920x1080 screen and + * scaled to the size the chain runs at, width with width and height with height: the first bloom level is * always half the screen, so every pixel counts and the glow stays round and the same size on any - * screen shape. + * screen shape. A target sized EtnaScale.SCREEN_SIZED is made the size of the screen itself: the scaled world + * chain runs on the smaller picture but keeps its anti-aliasing history at full size. * * Vanilla clears every target a chain takes for the frame, because a pass may draw over only part of * it. Every Etna pass draws every pixel of its target, so Etna's frame targets are taken as they are @@ -86,7 +89,7 @@ public abstract class PostChainMixin { CallbackInfoReturnable cir) { Identifier output = config.outputTarget(); etna$hdrPass = passId.getNamespace().equals(EtnaClient.MOD_ID) && !output.equals(PostChain.MAIN_TARGET_ID) - && !output.equals(ETNA_GRADED); + && !output.equals(EtnaScale.SCREEN) && !output.equals(ETNA_GRADED); } @ModifyArg( @@ -131,10 +134,16 @@ public abstract class PostChainMixin { index = 0 ) private int etna$targetWidth(int width) { - if (!this.id.getNamespace().equals(EtnaClient.MOD_ID) || width == etna$screenWidth) { + if (!this.id.getNamespace().equals(EtnaClient.MOD_ID)) { return width; } - return Math.max(1, Math.round(width * etna$screenWidth / 1920.0F)); + if (width == EtnaScale.SCREEN_SIZED) { + return EtnaScale.screenWidth(); + } + if (width > EtnaScale.REFERENCE_SIZED) { + return Math.max(1, Math.round((width - EtnaScale.REFERENCE_SIZED) * etna$screenWidth / 1920.0F)); + } + return width; } @ModifyArg( @@ -146,10 +155,16 @@ public abstract class PostChainMixin { index = 1 ) private int etna$targetHeight(int height) { - if (!this.id.getNamespace().equals(EtnaClient.MOD_ID) || height == etna$screenHeight) { + if (!this.id.getNamespace().equals(EtnaClient.MOD_ID)) { return height; } - return Math.max(1, Math.round(height * etna$screenHeight / 1080.0F)); + if (height == EtnaScale.SCREEN_SIZED) { + return EtnaScale.screenHeight(); + } + if (height > EtnaScale.REFERENCE_SIZED) { + return Math.max(1, Math.round((height - EtnaScale.REFERENCE_SIZED) * etna$screenHeight / 1080.0F)); + } + return height; } @ModifyArg( @@ -205,7 +220,7 @@ public abstract class PostChainMixin { index = 0 ) private Identifier etna$historyInTurn(Identifier target) { - if (!EtnaFrame.historySwapped() || !this.id.equals(EtnaFrame.WORLD)) { + if (!EtnaFrame.historySwapped() || !(this.id.equals(EtnaFrame.WORLD) || this.id.equals(EtnaFrame.WORLD_SCALED))) { return target; } if (target.equals(ETNA_HISTORY_READ)) { diff --git a/src/main/java/it/cosimo/etna/mixin/PostPassMixin.java b/src/main/java/it/cosimo/etna/mixin/PostPassMixin.java index 505e41a..5775d99 100644 --- a/src/main/java/it/cosimo/etna/mixin/PostPassMixin.java +++ b/src/main/java/it/cosimo/etna/mixin/PostPassMixin.java @@ -9,6 +9,7 @@ import it.cosimo.etna.EtnaClient; import it.cosimo.etna.EtnaLook; import it.cosimo.etna.EtnaMotes; +import it.cosimo.etna.EtnaScale; import it.cosimo.etna.EtnaShadows; import net.minecraft.client.renderer.PostChain; import net.minecraft.client.renderer.PostPass; @@ -35,7 +36,8 @@ * the chain points at main's depth as a stand-in, is swapped for the map (EtnaShadows.POST_MAP) before the * pass is first drawn. The pass's bindings were laid out from the chain's names, so they stay the same. * The same way, the last world pass's "EtnaMotes" block gets the fireflies' buffer (EtnaMotes), which it keeps - * for the session. + * for the session. Every input of an Etna pass that reads main's depth reads the level's depth instead + * (EtnaScale.LevelDepth), which with a render scale lies in the smaller target, not in main. * * Vanilla also opens every pass by loading what its target held, because a pass may draw over part of it. * Every Etna pass draws every pixel of its target with no blending, so an Etna pass that writes one of @@ -74,7 +76,7 @@ public abstract class PostPassMixin { } // The pass's location is its chain's id plus its index, so the namespace says whose chain it is. boolean etna = pipeline.getLocation().getNamespace().equals(EtnaClient.MOD_ID); - etna$ownTarget = etna && !outputTargetId.equals(PostChain.MAIN_TARGET_ID); + etna$ownTarget = etna && !outputTargetId.equals(PostChain.MAIN_TARGET_ID) && !outputTargetId.equals(EtnaScale.SCREEN); if (etna) { GpuBuffer shadowFixed = customUniforms.get(EtnaShadows.BLOCK); if (shadowFixed != null) { @@ -89,7 +91,8 @@ public abstract class PostPassMixin { etna$motesBlock = true; } // The pass keeps this same list (createPass makes it for the pass alone). - inputs.replaceAll(input -> input.samplerName().equals(EtnaShadows.MAP_INPUT) ? EtnaShadows.POST_MAP : input); + inputs.replaceAll(input -> input.samplerName().equals(EtnaShadows.MAP_INPUT) ? EtnaShadows.POST_MAP + : EtnaScale.LevelDepth.readsMainDepth(input) ? new EtnaScale.LevelDepth(input) : input); } } diff --git a/src/main/resources/assets/etna/lang/en_us.json b/src/main/resources/assets/etna/lang/en_us.json index 8189d51..c6e571d 100644 --- a/src/main/resources/assets/etna/lang/en_us.json +++ b/src/main/resources/assets/etna/lang/en_us.json @@ -28,6 +28,8 @@ "etna.options.profile.vivid": "Vivid", "etna.options.profile.cinematic": "Cinematic", "etna.options.profile.custom": "Custom", + "etna.options.renderScale": "World Resolution", + "etna.options.renderScale.desc": "How many pixels the world is drawn at, as a share of the screen's width and height. Below 100% Etna draws the world smaller and its anti-aliasing builds the full picture back up over a few frames, the way DLSS and FSR do: much faster, a little softer, mostly in motion. 75% draws about half the pixels. The hand, the HUD and menus stay sharp. Needs anti-aliasing, which every Quality but Low has.", "etna.options.resolution": "Resolution", "etna.options.resolution.desc": "Half draws a quarter of the pixels and lets macOS scale the picture up: much faster, but blurry. Applies after restarting the game, and only when Etna is on as the game starts.", "etna.options.resolution.full": "Retina", @@ -118,7 +120,7 @@ "etna.options.profiling.desc": "Summary writes a line to latest.log every few seconds while you play: frame rate, frame time and where it goes, on the CPU and on the GPU (timed on one frame in eight, so the frame rate stays the normal one). Off writes nothing.", "etna.options.profiling.summary": "Summary", "etna.options.benchmark": "Run benchmark", - "etna.options.benchmark.desc": "Takes about 130 seconds, in a world: Etna tries its settings one at a time, writes what each one costs to latest.log, then puts everything back as it was. Stand still and don't move the mouse: a step disturbed by a camera move or a stutter is measured again, which can add up to about 20 seconds. Turn any frame-rate limiter mod and VSync off first.", + "etna.options.benchmark.desc": "Takes about 135 seconds, in a world: Etna tries its settings one at a time, writes what each one costs to latest.log, then puts everything back as it was. Stand still and don't move the mouse: a step disturbed by a camera move or a stutter is measured again, which can add up to about 20 seconds. Turn any frame-rate limiter mod and VSync off first.", "etna.benchmark.start": "Etna benchmark: stand still and don't move the mouse for about %s s; turn the frame-rate limiter mod off first.", "etna.benchmark.done": "Etna benchmark finished: the results are in latest.log.", "etna.benchmark.unsteady": "Etna benchmark finished, but the scene changed during the run: the results in latest.log are rough. Run it again, standing still, once the chunks around have loaded.", diff --git a/src/main/resources/assets/etna/lang/it_it.json b/src/main/resources/assets/etna/lang/it_it.json index 7e7ecf8..2d7f6f5 100644 --- a/src/main/resources/assets/etna/lang/it_it.json +++ b/src/main/resources/assets/etna/lang/it_it.json @@ -28,6 +28,8 @@ "etna.options.profile.vivid": "Intenso", "etna.options.profile.cinematic": "Cinematico", "etna.options.profile.custom": "Personalizzato", + "etna.options.renderScale": "Risoluzione del mondo", + "etna.options.renderScale.desc": "Quanti pixel usa il mondo, in proporzione a larghezza e altezza dello schermo. Sotto il 100% Etna disegna il mondo più piccolo e l'anti-aliasing ricostruisce l'immagine piena in pochi fotogrammi, come fanno DLSS e FSR: molto più veloce, un po' più morbido, soprattutto in movimento. Al 75% i pixel sono circa la metà. Mano, HUD e menu restano nitidi. Serve l'anti-aliasing, attivo in ogni Qualità tranne Bassa.", "etna.options.resolution": "Risoluzione", "etna.options.resolution.desc": "Metà disegna un quarto dei pixel e lascia che macOS ingrandisca l'immagine: molto più veloce, ma sfocata. Vale dal prossimo avvio, e solo se Etna è attivo all'avvio del gioco.", "etna.options.resolution.full": "Retina", @@ -118,7 +120,7 @@ "etna.options.profiling.desc": "Con Riepilogo, mentre giochi, ogni pochi secondi scrive una riga in latest.log: frame al secondo, durata dei frame e dove va il tempo, sulla CPU e sulla GPU (misurata su un frame ogni otto, così gli fps restano quelli normali). Con No non scrive nulla.", "etna.options.profiling.summary": "Riepilogo", "etna.options.benchmark": "Avvia benchmark", - "etna.options.benchmark.desc": "Dura circa 130 secondi, in un mondo: Etna prova le sue impostazioni una alla volta, scrive in latest.log quanto costa ognuna e poi rimette tutto com'era. Stai fermo e non muovere il mouse: un passo disturbato da un movimento della visuale o da uno scatto viene misurato di nuovo, il che può aggiungere fino a una ventina di secondi. Prima disattiva la mod che limita i frame e il Sincronismo verticale.", + "etna.options.benchmark.desc": "Dura circa 135 secondi, in un mondo: Etna prova le sue impostazioni una alla volta, scrive in latest.log quanto costa ognuna e poi rimette tutto com'era. Stai fermo e non muovere il mouse: un passo disturbato da un movimento della visuale o da uno scatto viene misurato di nuovo, il che può aggiungere fino a una ventina di secondi. Prima disattiva la mod che limita i frame e il Sincronismo verticale.", "etna.benchmark.start": "Benchmark di Etna: stai fermo e non muovere il mouse per circa %s s; prima disattiva la mod che limita i frame al secondo.", "etna.benchmark.done": "Benchmark di Etna finito: i risultati sono in latest.log.", "etna.benchmark.unsteady": "Benchmark di Etna finito, ma la scena è cambiata durante la prova: i risultati in latest.log sono approssimativi. Rifallo stando fermo, quando i chunk intorno sono caricati.", diff --git a/src/main/resources/assets/etna/post_effect/etna.json b/src/main/resources/assets/etna/post_effect/etna.json index a4b162a..ca38ae2 100644 --- a/src/main/resources/assets/etna/post_effect/etna.json +++ b/src/main/resources/assets/etna/post_effect/etna.json @@ -1,68 +1,68 @@ { "targets": { "bloom0": { - "width": 960, - "height": 540 + "width": 20960, + "height": 20540 }, "up0": { - "width": 960, - "height": 540 + "width": 20960, + "height": 20540 }, "bloom1": { - "width": 480, - "height": 270 + "width": 20480, + "height": 20270 }, "up1": { - "width": 480, - "height": 270 + "width": 20480, + "height": 20270 }, "bloom2": { - "width": 240, - "height": 135 + "width": 20240, + "height": 20135 }, "up2": { - "width": 240, - "height": 135 + "width": 20240, + "height": 20135 }, "bloom3": { - "width": 120, - "height": 68 + "width": 20120, + "height": 20068 }, "up3": { - "width": 120, - "height": 68 + "width": 20120, + "height": 20068 }, "bloom4": { - "width": 60, - "height": 34 + "width": 20060, + "height": 20034 }, "up4": { - "width": 60, - "height": 34 + "width": 20060, + "height": 20034 }, "bloom5": { - "width": 30, - "height": 17 + "width": 20030, + "height": 20017 }, "glow0": { - "width": 480, - "height": 270 + "width": 20480, + "height": 20270 }, "glow1": { - "width": 240, - "height": 135 + "width": 20240, + "height": 20135 }, "glow2": { - "width": 120, - "height": 68 + "width": 20120, + "height": 20068 }, "glow3": { - "width": 60, - "height": 34 + "width": 20060, + "height": 20034 }, "glow": { - "width": 480, - "height": 270 + "width": 20480, + "height": 20270 }, "graded": {} }, @@ -80,6 +80,11 @@ "sampler_name": "Depth", "target": "minecraft:main", "use_depth_buffer": true + }, + { + "sampler_name": "HandDepth", + "target": "minecraft:main", + "use_depth_buffer": true } ], "output": "bloom0", diff --git a/src/main/resources/assets/etna/post_effect/etna_world.json b/src/main/resources/assets/etna/post_effect/etna_world.json index f8803f6..cee2afa 100644 --- a/src/main/resources/assets/etna/post_effect/etna_world.json +++ b/src/main/resources/assets/etna/post_effect/etna_world.json @@ -1,12 +1,12 @@ { "targets": { "shafts": { - "width": 960, - "height": 540 + "width": 20960, + "height": 20540 }, "water_ssr": { - "width": 960, - "height": 540 + "width": 20960, + "height": 20540 }, "air": {}, "history0": { diff --git a/src/main/resources/assets/etna/post_effect/etna_world_plain.json b/src/main/resources/assets/etna/post_effect/etna_world_plain.json index af2cb7d..87867fa 100644 --- a/src/main/resources/assets/etna/post_effect/etna_world_plain.json +++ b/src/main/resources/assets/etna/post_effect/etna_world_plain.json @@ -1,12 +1,12 @@ { "targets": { "shafts": { - "width": 960, - "height": 540 + "width": 20960, + "height": 20540 }, "water_ssr": { - "width": 960, - "height": 540 + "width": 20960, + "height": 20540 }, "lit": {} }, diff --git a/src/main/resources/assets/etna/post_effect/etna_world_scaled.json b/src/main/resources/assets/etna/post_effect/etna_world_scaled.json new file mode 100644 index 0000000..1b14f87 --- /dev/null +++ b/src/main/resources/assets/etna/post_effect/etna_world_scaled.json @@ -0,0 +1,225 @@ +{ + "targets": { + "shafts": { + "width": 20960, + "height": 20540 + }, + "water_ssr": { + "width": 20960, + "height": 20540 + }, + "air": {}, + "history0": { + "persistent": true, + "width": 16384, + "height": 16384 + }, + "history1": { + "persistent": true, + "width": 16384, + "height": 16384 + } + }, + "passes": [ + { + "vertex_shader": "minecraft:core/screentriangle", + "fragment_shader": "etna:post/shafts", + "inputs": [ + { + "sampler_name": "Depth", + "target": "minecraft:main", + "use_depth_buffer": true + }, + { + "sampler_name": "EtnaShadow", + "target": "minecraft:main", + "use_depth_buffer": true + } + ], + "output": "shafts", + "uniforms": { + "EtnaSettings": [ + { + "name": "Placeholder", + "type": "vec4", + "value": [ + 0.0, + 0.0, + 0.0, + 0.0 + ] + } + ], + "EtnaShadow": [ + { + "name": "Placeholder", + "type": "vec4", + "value": [ + 0.0, + 0.0, + 0.0, + 0.0 + ] + } + ] + } + }, + { + "vertex_shader": "minecraft:core/screentriangle", + "fragment_shader": "etna:post/water_ssr", + "inputs": [ + { + "sampler_name": "In", + "target": "minecraft:main" + }, + { + "sampler_name": "Depth", + "target": "minecraft:main", + "use_depth_buffer": true + } + ], + "output": "water_ssr", + "uniforms": { + "EtnaSettings": [ + { + "name": "Placeholder", + "type": "vec4", + "value": [ + 0.0, + 0.0, + 0.0, + 0.0 + ] + } + ], + "EtnaShadow": [ + { + "name": "Placeholder", + "type": "vec4", + "value": [ + 0.0, + 0.0, + 0.0, + 0.0 + ] + } + ] + } + }, + { + "vertex_shader": "minecraft:core/screentriangle", + "fragment_shader": "etna:post/world_light", + "inputs": [ + { + "sampler_name": "In", + "target": "minecraft:main" + }, + { + "sampler_name": "Depth", + "target": "minecraft:main", + "use_depth_buffer": true + }, + { + "sampler_name": "Shafts", + "target": "shafts" + }, + { + "sampler_name": "WaterSsr", + "target": "water_ssr" + } + ], + "output": "air", + "uniforms": { + "EtnaSettings": [ + { + "name": "Placeholder", + "type": "vec4", + "value": [ + 0.0, + 0.0, + 0.0, + 0.0 + ] + } + ] + } + }, + { + "vertex_shader": "minecraft:core/screentriangle", + "fragment_shader": "etna:post/taa", + "inputs": [ + { + "sampler_name": "In", + "target": "air" + }, + { + "sampler_name": "History", + "target": "history0", + "bilinear": true + }, + { + "sampler_name": "Depth", + "target": "minecraft:main", + "use_depth_buffer": true + } + ], + "output": "history1", + "uniforms": { + "EtnaSettings": [ + { + "name": "Placeholder", + "type": "vec4", + "value": [ + 0.0, + 0.0, + 0.0, + 0.0 + ] + } + ] + } + }, + { + "vertex_shader": "minecraft:core/screentriangle", + "fragment_shader": "etna:post/copy_dither", + "inputs": [ + { + "sampler_name": "In", + "target": "history1" + }, + { + "sampler_name": "Depth", + "target": "minecraft:main", + "use_depth_buffer": true + } + ], + "output": "etna:screen", + "uniforms": { + "EtnaSettings": [ + { + "name": "Placeholder", + "type": "vec4", + "value": [ + 0.0, + 0.0, + 0.0, + 0.0 + ] + } + ], + "EtnaMotes": [ + { + "name": "Placeholder", + "type": "vec4", + "value": [ + 0.0, + 0.0, + 0.0, + 0.0 + ] + } + ] + } + } + ] +} diff --git a/src/main/resources/assets/etna/shaders/core/terrain.fsh b/src/main/resources/assets/etna/shaders/core/terrain.fsh index a7562dc..fcabd48 100644 --- a/src/main/resources/assets/etna/shaders/core/terrain.fsh +++ b/src/main/resources/assets/etna/shaders/core/terrain.fsh @@ -805,8 +805,15 @@ void main() { // discard they are undefined next to cut-out edges (leaves, plants). vec3 posDx = dFdx(etnaViewPos); vec3 posDy = dFdy(etnaViewPos); - vec2 uvDx = dFdx(texCoord0); - vec2 uvDy = dFdy(texCoord0); + // With a render scale (EtnaScale) a pixel here covers more of the screen than the picture's own pixels; the + // texture's detail is picked for the screen's pixels, so the textures stay as sharp as at full size and the + // anti-aliasing, which sees every part of each screen pixel over a few frames, keeps them steady. + // The water's average (etnaWaterTexture, etnaWaterSide) compares the texture's steps with the position's, so it + // takes the derivatives as they are. + vec2 uvDxPicture = dFdx(texCoord0); + vec2 uvDyPicture = dFdy(texCoord0); + vec2 uvDx = uvDxPicture * ETNA_RENDER_SCALE; + vec2 uvDy = uvDyPicture * ETNA_RENDER_SCALE; #ifndef OIT_ALPHA_ONLY etnaPixelFootprint = max(length(posDx), length(posDy)); etnaTexelFootprint = max(length(uvDx * vec2(TextureSize)), length(uvDy * vec2(TextureSize))); @@ -827,16 +834,16 @@ void main() { vec3 inBlock = fract(etnaViewPos - CameraOffset); if (faceNormal.y >= max(faceNormal.x, faceNormal.z)) { if (faceNormal.y > 1e-3 * length(posDx.xz) * length(posDy.xz)) { - color = etnaWaterTexture(uvDx, uvDy, posDx, posDy); + color = etnaWaterTexture(uvDxPicture, uvDyPicture, posDx, posDy); sampled = true; } } else if (faceNormal.x >= faceNormal.z) { if (faceNormal.x > 1e-3 * length(posDx.zy) * length(posDy.zy)) { - color = etnaWaterSide(uvDx, uvDy, posDx.zy, posDy.zy, inBlock.zy); + color = etnaWaterSide(uvDxPicture, uvDyPicture, posDx.zy, posDy.zy, inBlock.zy); sampled = true; } } else if (faceNormal.z > 1e-3 * length(posDx.xy) * length(posDy.xy)) { - color = etnaWaterSide(uvDx, uvDy, posDx.xy, posDy.xy, inBlock.xy); + color = etnaWaterSide(uvDxPicture, uvDyPicture, posDx.xy, posDy.xy, inBlock.xy); sampled = true; } } diff --git a/src/main/resources/assets/etna/shaders/include/terrain_ubo.glsl b/src/main/resources/assets/etna/shaders/include/terrain_ubo.glsl index b276016..4938451 100644 --- a/src/main/resources/assets/etna/shaders/include/terrain_ubo.glsl +++ b/src/main/resources/assets/etna/shaders/include/terrain_ubo.glsl @@ -34,7 +34,7 @@ layout(std140) uniform EtnaShadow { mat4 EtnaShadowMat2; // 432. the second shadow map (EtnaShadowSampler2), as EtnaShadowMat for the first, while the terrain fades from one to the other as the sun moves (EtnaShadows.chooseMap); EtnaShadowMat the rest of the time vec4 EtnaShadowFade; // 496. x: how much of the second map shadowVisibility and leafBacklight mix in (0 .. 0.5; 0: the first alone), y: the Nether's lava light from below in vanilla's units (0 outside the Nether; EtnaAir.lavaLight, terrain_dark.glsl), z: the lava sea's surface as a world height, w: reserved (0) vec4 EtnaSurface; // 512. settings menu (EtnaMaterials.java). x: materials, y: puddles, z: sparkles, w: glowing flowers (each 0 .. 1) - vec4 EtnaSurface2; // 528. x: how much the bright specks of glowing blocks glow (the glowing blocks setting, 0 .. 1), yzw: reserved (0) + vec4 EtnaSurface2; // 528. x: how much the bright specks of glowing blocks glow (the glowing blocks setting, 0 .. 1), y: the share of the screen's width the level is drawn at (EtnaScale: 1 at full size), zw: this frame's anti-aliasing jitter, in the level's pixels (EtnaFrame: x right, y down; 0 without anti-aliasing) vec4 EtnaNight; // 544. the aurora's light on the land (EtnaSky.writeNight). rgb: its colour, luma 1 (a green with a little violet), w: how much aurora is in the sky (0 .. 1, eased over the biome, the moon, the night and the weather; 0 by day, in rain, outside the overworld and with Etna or the Aurora setting off) vec4 EtnaWaterSsr; // 560. settings menu (EtnaWaterSsr.java). x: shoreline foam (0 .. 1), y: glitter (0 .. 1); for the reflections pass (water_ssr.fsh): z: estimate of etnaDaylight, the lightmap under the open sky (0 .. 1), w: the camera's height above a whole block (camera y - floor(camera y)) }; @@ -65,6 +65,9 @@ layout(std140) uniform EtnaShadow { #define ETNA_LEAF_GLOW EtnaMaterial.z #define ETNA_TORCH_WARMTH EtnaMaterial.w #define ETNA_WETNESS EtnaHandPos.w +// The level's render scale (EtnaScale); 0 only in a block written before 0.9.0, read as full size. +#define ETNA_RENDER_SCALE (EtnaSurface2.y > 0.0 ? EtnaSurface2.y : 1.0) +#define ETNA_JITTER_NOW EtnaSurface2.zw // Etna's sky model (sky.glsl): whether it describes this frame's sky (its skylight is never black, and // EtnaSky.java leaves every sky field at 0 when it has no model: no overworld sky, or the model failed), // the colours of the sunlight and the skylight, and whether Etna drew the sky this frame. @@ -90,6 +93,8 @@ layout(std140) uniform EtnaShadow { #define ETNA_LEAF_GLOW 0.6 #define ETNA_TORCH_WARMTH 0.0 #define ETNA_WETNESS 0.0 +#define ETNA_RENDER_SCALE 1.0 +#define ETNA_JITTER_NOW vec2(0.0) // No sky model here: 0.6.0's colours (sunColor and skyColor in terrain.fsh), and vanilla's fog colour. #define ETNA_SKY_MODEL false #define ETNA_SKY_DRAWN false diff --git a/src/main/resources/assets/etna/shaders/include/terrain_water.glsl b/src/main/resources/assets/etna/shaders/include/terrain_water.glsl index e9910e7..9988719 100644 --- a/src/main/resources/assets/etna/shaders/include/terrain_water.glsl +++ b/src/main/resources/assets/etna/shaders/include/terrain_water.glsl @@ -414,13 +414,6 @@ float waterGlitter(vec2 p, float t, float footprint, vec2 seen, vec2 target, flo #endif } -// Temporal anti-aliasing moves the level's picture by a fraction of a pixel every frame: EtnaFrame's -// 8-step Halton sequence (bases 2 and 3, less 0.5), in pixels, one step per frame. Keep the two in step. -const vec2 ETNA_JITTER[8] = vec2[8]( - vec2(0.0, -0.16666667), vec2(-0.25, 0.16666667), vec2(0.25, -0.38888889), vec2(-0.375, -0.05555556), - vec2(0.125, 0.27777778), vec2(-0.125, -0.27777778), vec2(0.375, 0.05555556), vec2(-0.4375, 0.38888889) -); - // From this fragment to the point its pixel's centre would show without this frame's jitter, camera- // relative. Detail drawn there holds still from frame to frame; drawn where the jitter puts it, small // detail that moves (glints on moving waves) shimmers, because the anti-aliasing drops the old frames @@ -430,8 +423,9 @@ const vec2 ETNA_JITTER[8] = vec2[8]( vec3 etnaUnjitter() { #ifdef ETNA_SHADOWS if (EtnaWorld.y > 0.5) { - // EtnaLook2.z is the frame number modulo 4096, a multiple of the sequence's 8 steps. - vec2 jitter = ETNA_JITTER[int(EtnaLook2.z + 0.5) & 7]; + // Temporal anti-aliasing moves the level's picture by a fraction of a pixel every frame (EtnaFrame's + // Halton sequence, in the level's pixels). + vec2 jitter = ETNA_JITTER_NOW; // The jitter moves the picture by +jitter pixels in window space (x right, y down, as dFdx and // dFdy count), so the centre's own point lies +jitter pixels on along the surface. vec3 shift = dFdx(etnaViewPos) * jitter.x + dFdy(etnaViewPos) * jitter.y; diff --git a/src/main/resources/assets/etna/shaders/post/bloom_prefilter.fsh b/src/main/resources/assets/etna/shaders/post/bloom_prefilter.fsh index d6297b1..fd33f50 100644 --- a/src/main/resources/assets/etna/shaders/post/bloom_prefilter.fsh +++ b/src/main/resources/assets/etna/shaders/post/bloom_prefilter.fsh @@ -22,11 +22,16 @@ uniform sampler2D InSampler; uniform sampler2D DepthSampler; +// Main's own depth. With a render scale (EtnaScale) the level's depth comes from the smaller picture +// (DepthSampler) and main's holds only the hand, drawn after the world at full size: there it says where the +// hand covers the land. Without one both are the same texture, with the hand already merged in. +uniform sampler2D HandDepthSampler; layout(std140) uniform SamplerInfo { vec2 OutSize; vec2 InSize; vec2 DepthSize; + vec2 HandDepthSize; }; layout(location = 0) in vec2 texCoord; @@ -49,15 +54,20 @@ vec3 sceneTap(vec2 offset) { // How much of the bloom fog this output pixel takes: the four depth texels under it, each turned into its // share by its distance and averaged, so an edge between near and far land feeds smoothly as the -// anti-aliasing jitter moves it. The sky (nothing drawn) is as far as it gets. +// anti-aliasing jitter moves it. The sky (nothing drawn) is as far as it gets. The hand feeds none. float fogShare() { ivec2 base = ivec2(texCoord * DepthSize - 0.5); ivec2 last = ivec2(DepthSize) - 1; + bool separateHand = HandDepthSize != DepthSize; float share = 0.0; for (int i = 0; i < 4; i++) { ivec2 p = clamp(base + ivec2(i & 1, i >> 1), ivec2(0), last); + vec2 uv = (vec2(p) + 0.5) / DepthSize; + if (separateHand && !etnaIsSky(texelFetch(HandDepthSampler, ivec2(uv * HandDepthSize), 0).r)) { + continue; + } float depth = texelFetch(DepthSampler, p, 0).r; - float dist = etnaIsSky(depth) ? 1e4 : length(etnaPosition((vec2(p) + 0.5) / DepthSize, depth)); + float dist = etnaIsSky(depth) ? 1e4 : length(etnaPosition(uv, depth)); share += 1.0 - exp(-dist * Glow.w); } return 0.25 * share; diff --git a/src/main/resources/assets/etna/shaders/post/copy_dither.fsh b/src/main/resources/assets/etna/shaders/post/copy_dither.fsh index 35e3628..4864c74 100644 --- a/src/main/resources/assets/etna/shaders/post/copy_dither.fsh +++ b/src/main/resources/assets/etna/shaders/post/copy_dither.fsh @@ -25,6 +25,8 @@ uniform sampler2D DepthSampler; layout(std140) uniform SamplerInfo { vec2 OutSize; + vec2 InSize; + vec2 DepthSize; }; // Most motes drawn at once, and the tiles the screen is cut into for them (EtnaMotes.java has the same). @@ -53,7 +55,8 @@ vec3 motes(ivec2 pixel) { if (mask == 0u) { return vec3(0.0); } - float depth = texelFetch(DepthSampler, pixel, 0).r; + // The depth is the level's: smaller than this pass's pixels with a render scale (EtnaScale). + float depth = texelFetch(DepthSampler, min(ivec2(texCoord * DepthSize), ivec2(DepthSize) - 1), 0).r; float surface = etnaIsSky(depth) ? 1.0e6 : length(etnaPosition(texCoord, depth)); vec3 light = vec3(0.0); for (int i = 0; mask != 0u; i++, mask >>= 1u) { diff --git a/src/main/resources/assets/etna/shaders/post/shafts.fsh b/src/main/resources/assets/etna/shaders/post/shafts.fsh index bacf2cb..76daeb1 100644 --- a/src/main/resources/assets/etna/shaders/post/shafts.fsh +++ b/src/main/resources/assets/etna/shaders/post/shafts.fsh @@ -48,9 +48,13 @@ const float SHAFT_CURVE = 4.0; const float SHAFT_SCALE = 15.0; const float SHAFT_RATIO = 1.41421356; -// Which pixel of the 2x2 block a texel stands for, frame by frame: the two diagonals in turn (the same order -// as Photon Shaders' checkerboard.glsl). -const ivec2 SUBPIXEL[4] = ivec2[4](ivec2(0, 0), ivec2(1, 1), ivec2(1, 0), ivec2(0, 1)); +// Which pixel of the 2x2 block a texel stands for on frame i (0 .. 3): the cells of the 2x2 ordered-dither matrix +// (Bryce Bayer, "An optimum method for two-level rendition of continuous-tone pictures", 1973) in the order of +// their thresholds, read down the columns. Each pair of frames covers a diagonal, so any two frames in a row +// already spread over the block, and four cover it all. +ivec2 subpixel(int i) { + return ivec2(i & 1, (i ^ (i >> 1)) & 1); +} // 1 where the sun reaches a point of the ray, given as a position in the shadow map's clip space, 0 where // something nearer the light blocks it. The same test and bias as the terrain's unfiltered lookups @@ -74,7 +78,7 @@ float sunAt(vec3 clip) { void main() { ivec2 cell = ivec2(gl_FragCoord.xy); - ivec2 sub = Frame.w > 0.5 ? SUBPIXEL[int(Frame.x) & 3] : ivec2(0); + ivec2 sub = Frame.w > 0.5 ? subpixel(int(Frame.x) & 3) : ivec2(0); ivec2 pixel = min(cell * 2 + sub, ivec2(DepthSize) - 1); float depth = texelFetch(DepthSampler, pixel, 0).r; vec2 uv = (vec2(pixel) + 0.5) / DepthSize; diff --git a/src/main/resources/assets/etna/shaders/post/taa.fsh b/src/main/resources/assets/etna/shaders/post/taa.fsh index ab5d874..a4650e6 100644 --- a/src/main/resources/assets/etna/shaders/post/taa.fsh +++ b/src/main/resources/assets/etna/shaders/post/taa.fsh @@ -1,14 +1,39 @@ #version 330 #extension GL_ARB_separate_shader_objects : require -// Etna world pass 2: temporal anti-aliasing, the pass that keeps the picture steady. Ported from -// Photon Shaders (by SixthSurge: program/c4_taa_exposure.fsh and include/utility/bicubic.glsl), -// under the Photon Shaders License (LICENSE-PHOTON.txt, see CREDITS.md), and adapted to Minecraft's reversed depth buffer and Etna's camera matrices. -// Every frame the camera is moved by a fraction of a pixel (EtnaFrame.java). Here last frame's -// result is found again through the camera's movement, read back with a sharp Catmull-Rom filter and -// blended with the new frame, after being pulled into the range of colours around the pixel in the -// new frame, so moving things leave no trails. Edges stop crawling, and the noise of the passes -// before (ambient occlusion, light shafts, soft shadows) averages out. +// Etna world pass 2: temporal anti-aliasing, the pass that keeps the picture steady, and that can also +// rebuild a full-size picture from a world drawn at a lower resolution (temporal upsampling). +// +// Every frame the camera is moved by a different fraction of a pixel (a Halton sequence, EtnaFrame.java; +// 8 steps at full size, 16 when the world is drawn at about 0.75 of the screen per axis), so over a few +// frames the pixels of the level see every part of their area. This pass keeps a running average of +// those frames, at the size of the screen: +// +// - This frame's contribution is rebuilt from the 3x3 level pixels around the screen pixel, each +// weighted by how close the point it saw (its centre moved by the jitter) lies to the screen pixel's +// centre: a small Gaussian, so a sample right on the centre counts most. The weights also say how much +// this frame knows about the pixel, and the running average adds them up (alpha keeps the total, in +// frames' worth of samples, so a fresh history takes this frame's share as 1, 1/2, 1/3 ... down to +// the settled share): a frame whose samples all landed far from the centre adds little and the +// history carries the pixel. This follows Yang, Nehab, Sander, Sitthi-amorn, Lawrence and Hoppe, +// "Amortized Supersampling" (2009), which weighs each new sample by its distance to the pixel centre, +// and is how temporal upsampling rebuilds an output pixel from jittered samples of a smaller picture +// (Unreal Engine's documentation of TAAU and screen percentage). At full size the centre sample +// carries almost all the weight and this is plain temporal anti-aliasing. +// - Last frame's result is found again through the camera's movement, with the depth of the nearest +// surface around the pixel so the edges of near things move with them (Brian Karis, "High Quality +// Temporal Supersampling", SIGGRAPH 2014), and read back with a Catmull-Rom spline from five bilinear +// reads (Jorge Jimenez, "Filmic SMAA", SIGGRAPH 2016), which stays sharp where a bilinear read would +// blur a little more every frame. +// - Before the blend that history is pulled into the box of colours found around the pixel in this +// frame, in YCoCg (Karis 2014), sized by the mean and standard deviation of those colours (Marco Salvi, +// "An Excursion in Temporal Supersampling", GDC 2016), along the line towards the box's centre (Lasse +// Jon Fuglsang Pedersen, "Temporal Reprojection Anti-Aliasing in INSIDE", GDC 2016). Moving things and +// newly uncovered surfaces leave no trails, while the per-frame noise of the passes before (ambient +// occlusion, light shafts, soft shadows) stays inside the box and averages out. +// +// Edges stop crawling and the noise settles, and with the world drawn smaller than the screen the jitter +// fills in the detail the smaller picture misses. #include @@ -27,173 +52,171 @@ layout(location = 0) in vec2 texCoord; layout(location = 0) out vec4 fragColor; -// How much a sample far from a history pixel's centre is trusted less (less blur in motion). -const float OFFCENTER_REJECTION = 0.25; - -vec3 minOf(vec3 a, vec3 b, vec3 c, vec3 d, vec3 f) { - return min(a, min(b, min(c, min(d, f)))); -} - -vec3 maxOf(vec3 a, vec3 b, vec3 c, vec3 d, vec3 f) { - return max(a, max(b, max(c, max(d, f)))); -} - -// Blending happens on a compressed scale, so bright pixels don't dominate the average. -vec3 reinhard(vec3 rgb) { - return rgb / (rgb + 1.0); -} - -vec3 reinhardInverse(vec3 rgb) { - return rgb / (1.0 - rgb); -} - -vec3 rgbToYcocg(vec3 rgb) { - const mat3 cm = mat3(0.25, 0.5, 0.25, 0.5, 0.0, -0.5, -0.25, 0.5, -0.25); - return rgb * cm; +// This frame's share of the result once the history is full, close to the camera and far away. Far off +// a pixel covers many texels and the noise of the passes before is strongest, so the history counts more +// there; close by the picture follows changes in light sooner. +const float SHARE_NEAR = 0.18; +const float SHARE_FAR = 0.08; +// How the share eases from near to far: 1 - exp2(-falloff * distance in blocks) mixes them. +const float SHARE_FALLOFF = 0.025; +// Spread (standard deviation) of the Gaussian that weighs this frame's samples, in screen pixels. Small +// enough that the result is about as sharp as a pixel-sized box, wide enough that a sample between two +// pixels counts for both. +const float SAMPLE_SPREAD = 0.35; +// Half the size of the colour box around the pixel, in standard deviations of the colours in it. The +// noise of the passes before widens the box by itself; at a smaller render the box is widened by the +// render scale, as its samples lie further apart and say less about the screen pixel. +const float BOX_SIZE = 1.5; +// How much less the history counts when it is read half way between its pixels (where even the +// Catmull-Rom spline softens it a little), so a moving picture keeps its edges. +const float BETWEEN_PIXELS_LOSS = 0.5; +// How much less the history counts for each box size it had to be pulled in by: where something moved +// or was uncovered, this frame takes over sooner. +const float CLIP_FORGET = 0.5; +// This frame's weight (1: an average frame at full size) from which the history is pulled all the way +// into the box. A frame whose samples all landed far from the pixel (only at a smaller render) pulls it +// in less: its box is a poor guess, and pulling the history towards it would blur the detail earlier +// frames found. At full size every frame weighs more than this, so the clip is always complete. +const float FULL_CLIP_WEIGHT = 1.0; + +vec3 toYCoCg(vec3 c) { + return vec3(0.25 * c.r + 0.5 * c.g + 0.25 * c.b, 0.5 * (c.r - c.b), 0.5 * c.g - 0.25 * (c.r + c.b)); } -vec3 ycocgToRgb(vec3 ycocg) { - float tmp = ycocg.x - ycocg.z; - return vec3(tmp + ycocg.y, ycocg.x + ycocg.z, tmp - ycocg.y); +vec3 fromYCoCg(vec3 c) { + float t = c.x - c.z; + return vec3(t + c.y, c.x + c.z, t - c.y); } -// The nearest surface among five pixels in a cross: things in front reproject with their own -// movement, so their edges don't smear into what's behind them. Reversed depth: nearest is biggest. -vec3 closestFragment(ivec2 texel0) { - ivec2 limit = ivec2(DepthSize) - 1; - ivec2 texel1 = clamp(texel0 + ivec2(-2, -2), ivec2(0), limit); - ivec2 texel2 = clamp(texel0 + ivec2(2, -2), ivec2(0), limit); - ivec2 texel3 = clamp(texel0 + ivec2(-2, 2), ivec2(0), limit); - ivec2 texel4 = clamp(texel0 + ivec2(2, 2), ivec2(0), limit); - - float depth0 = texelFetch(DepthSampler, texel0, 0).r; - float depth1 = texelFetch(DepthSampler, texel1, 0).r; - float depth2 = texelFetch(DepthSampler, texel2, 0).r; - float depth3 = texelFetch(DepthSampler, texel3, 0).r; - float depth4 = texelFetch(DepthSampler, texel4, 0).r; - - vec3 pos = depth0 > depth1 ? vec3(texel0, depth0) : vec3(texel1, depth1); - vec3 pos1 = depth2 > depth3 ? vec3(texel2, depth2) : vec3(texel3, depth3); - pos = pos.z > pos1.z ? pos : pos1; - pos = pos.z > depth4 ? pos : vec3(texel4, depth4); - - return vec3((pos.xy + 0.5) / DepthSize, pos.z); -} - -// AABB clipping from "Temporal Reprojection Anti-Aliasing in INSIDE". -vec3 clipAabb(vec3 historyColor, vec3 minColor, vec3 maxColor) { - vec3 pClip = 0.5 * (maxColor + minColor); - vec3 eClip = 0.5 * (maxColor - minColor); - vec3 vClip = historyColor - pClip; - vec3 vUnit = vClip / max(eClip, 1e-3); - vec3 aUnit = abs(vUnit); - float maUnit = max(aUnit.x, max(aUnit.y, aUnit.z)); - return maUnit > 1.0 ? pClip + vClip / maUnit : historyColor; -} - -vec3 fetchYcocg(ivec2 texel) { - texel = clamp(texel, ivec2(0), ivec2(InSize) - 1); - return rgbToYcocg(reinhard(texelFetch(InSampler, texel, 0).rgb)); -} - -vec3 neighborhoodClipping(ivec2 texel, vec3 currentColor, vec3 historyColor, float distanceFactor) { - // 3x3 neighbourhood - // a b c - // d e f - // g h i - vec3 a = fetchYcocg(texel + ivec2(-1, 1)); - vec3 b = fetchYcocg(texel + ivec2(0, 1)); - vec3 c = fetchYcocg(texel + ivec2(1, 1)); - vec3 d = fetchYcocg(texel + ivec2(-1, 0)); - vec3 e = rgbToYcocg(currentColor); - vec3 f = fetchYcocg(texel + ivec2(1, 0)); - vec3 g = fetchYcocg(texel + ivec2(-1, -1)); - vec3 h = fetchYcocg(texel + ivec2(0, -1)); - vec3 i = fetchYcocg(texel + ivec2(1, -1)); - - // Soft minimum and maximum ("Hybrid Reconstruction Antialiasing") - vec3 minColor = minOf(b, d, e, f, h); - minColor += minOf(minColor, a, c, g, i); - minColor *= 0.5; - vec3 maxColor = maxOf(b, d, e, f, h); - maxColor += maxOf(maxColor, a, c, g, i); - maxColor *= 0.5; - - // Variance clipping ("An Excursion in Temporal Supersampling"). Higher gamma: steadier but more - // ghosting, so it is only relaxed far from the camera. - vec3 mu = (a + b + c + d + e + f + g + h + i) * (1.0 / 9.0); - vec3 m2 = (a * a + b * b + c * c + d * d + e * e + f * f + g * g + h * h + i * i) * (1.0 / 9.0); - vec3 sigma = sqrt(max(m2 - mu * mu, 0.0)); - float gamma = mix(0.75, 1.25, clamp((distanceFactor - 0.25) / 0.75, 0.0, 1.0)); - minColor = max(minColor, mu - gamma * sigma); - maxColor = min(maxColor, mu + gamma * sigma); - - return ycocgToRgb(clipAabb(rgbToYcocg(historyColor), minColor, maxColor)); -} - -// Catmull-Rom from five bilinear taps (Siggraph 2016 SMAA presentation), so the history stays sharp -// however many frames it is carried. -vec3 catmullRomFast(sampler2D tex, vec2 coord, const float sharpness) { - vec2 res = HistorySize; - vec2 pixelSize = 1.0 / res; - vec2 position = res * coord; - vec2 centerPosition = floor(position - 0.5) + 0.5; - vec2 f = position - centerPosition; - vec2 f2 = f * f; - vec2 f3 = f * f2; - - vec2 w0 = -sharpness * f3 + 2.0 * sharpness * f2 - sharpness * f; - vec2 w1 = (2.0 - sharpness) * f3 - (3.0 - sharpness) * f2 + 1.0; - vec2 w2 = -(2.0 - sharpness) * f3 + (3.0 - 2.0 * sharpness) * f2 + sharpness * f; - vec2 w3 = sharpness * f3 - sharpness * f2; - +// Last frame's result at uv through a Catmull-Rom spline over its 4x4 nearest pixels. The spline's +// weights along each axis, for a point t (0..1) of the way from pixel 1 to pixel 2, are +// w0 = (-t + 2t^2 - t^3) / 2, w1 = (2 - 5t^2 + 3t^3) / 2, w2 = (t + 4t^2 - 3t^3) / 2, w3 = (t^3 - t^2) / 2. +// The two middle pixels are both positive and are read together by one bilinear read placed between them +// in the ratio w2 : w1, which leaves a 3x3 grid of reads; the four corners (each the product of two small +// outer weights) are left out and the rest renormalised, so five reads stand for sixteen (Jimenez 2016). +// between: how far the point lies from a history pixel's centre, 0 on a centre, 1 half way to the next. +vec4 historyAt(vec2 uv, out float between) { + vec2 pos = uv * HistorySize - 0.5; + vec2 first = floor(pos); + vec2 t = pos - first; + vec2 t2 = t * t; + vec2 t3 = t2 * t; + vec2 w0 = 0.5 * (-t + 2.0 * t2 - t3); + vec2 w1 = 0.5 * (2.0 - 5.0 * t2 + 3.0 * t3); + vec2 w2 = 0.5 * (t + 4.0 * t2 - 3.0 * t3); + vec2 w3 = 0.5 * (t3 - t2); vec2 w12 = w1 + w2; - vec2 tc12 = pixelSize * (centerPosition + w2 / w12); - vec3 centerColor = texture(tex, tc12).rgb; - vec2 tc0 = pixelSize * (centerPosition - 1.0); - vec2 tc3 = pixelSize * (centerPosition + 2.0); - - vec4 color = vec4(texture(tex, vec2(tc12.x, tc0.y)).rgb, 1.0) * (w12.x * w0.y) - + vec4(texture(tex, vec2(tc0.x, tc12.y)).rgb, 1.0) * (w0.x * w12.y) - + vec4(centerColor, 1.0) * (w12.x * w12.y) - + vec4(texture(tex, vec2(tc3.x, tc12.y)).rgb, 1.0) * (w3.x * w12.y) - + vec4(texture(tex, vec2(tc12.x, tc3.y)).rgb, 1.0) * (w12.x * w3.y); - return color.rgb / color.a; + vec2 texel = 1.0 / HistorySize; + // Pixel n's centre is at n + 0.5: pixel `first` is spline point 1. + vec2 at0 = (first - 0.5) * texel; + vec2 at12 = (first + 0.5 + w2 / w12) * texel; + vec2 at3 = (first + 2.5) * texel; + float wUp = w12.x * w0.y; + float wLeft = w0.x * w12.y; + float wMid = w12.x * w12.y; + float wRight = w3.x * w12.y; + float wDown = w12.x * w3.y; + vec4 sum = texture(HistorySampler, vec2(at12.x, at0.y)) * wUp + + texture(HistorySampler, vec2(at0.x, at12.y)) * wLeft + + texture(HistorySampler, at12) * wMid + + texture(HistorySampler, vec2(at3.x, at12.y)) * wRight + + texture(HistorySampler, vec2(at12.x, at3.y)) * wDown; + vec2 off = 4.0 * t * (1.0 - t); + between = 0.5 * (off.x + off.y); + return sum / (wUp + wLeft + wMid + wRight + wDown); } void main() { - ivec2 texel = ivec2(gl_FragCoord.xy); - vec3 currentColor = texelFetch(InSampler, texel, 0).rgb; - - vec3 closest = closestFragment(texel); - // The sky sits on the far plane; a hair in front of it keeps the maths finite. - vec3 position = etnaPosition(closest.xy, max(closest.z, 1e-6)); - vec2 velocity = closest.xy - etnaScreen(PrevViewProj, position + Motion.xyz).xy; - vec2 previousUv = texCoord - velocity; - - vec3 historyColor = max(catmullRomFast(HistorySampler, previousUv, 0.6), 0.0); - bool inside = all(greaterThanEqual(previousUv, vec2(0.0))) && all(lessThanEqual(previousUv, vec2(1.0))); - float age = texelFetch(HistorySampler, clamp(ivec2(previousUv * HistorySize), ivec2(0), ivec2(HistorySize) - 1), 0).a; - // A fresh history (first frame, resized window, teleport) starts over from this frame alone. - age = Motion.w > 0.5 && inside ? max(age, 0.0) + 1.0 : 1.0; - - // Favour responsiveness close to the camera and stability further away. Photon starts at 0.35 - // next to the camera; Etna's ambient occlusion, light shafts and soft shadows change their noise - // every frame and count on this pass alone to average it, and at 0.35 a fifth of that noise still - // showed as shimmer in the shade around the player. The clipping above keeps the lower weight - // from leaving trails. - float distanceFactor = 1.0 - exp2(-0.025 * length(position)); - float blendWeight = mix(0.18, 0.08, distanceFactor); - float alpha = max(1.0 / age, blendWeight); - - currentColor = reinhard(currentColor); - historyColor = neighborhoodClipping(texel, currentColor, reinhard(historyColor), distanceFactor); - - // Offcenter rejection (Jessie, originally Zombye): reduces blur in motion. - vec2 pixelOffset = 1.0 - abs(2.0 * fract(HistorySize * previousUv) - 1.0); - float offcenter = sqrt(pixelOffset.x * pixelOffset.y) * OFFCENTER_REJECTION + (1.0 - OFFCENTER_REJECTION); - alpha = 1.0 - (1.0 - alpha) * offcenter; - - vec3 result = reinhardInverse(min(mix(historyColor, currentColor, alpha), vec3(0.999))); - fragColor = vec4(result, age * offcenter); + // Level pixel k shows the point that, without the jitter, would sit at k + 0.5 - jitter (in level + // pixels): the jitter moves the picture by +jitter. scale turns level pixels into screen pixels + // (1 at full size, above 1 when the world is drawn smaller). + vec2 jitter = Frame.yz; + vec2 scale = OutSize / InSize; + vec2 here = texCoord * InSize; + ivec2 last = ivec2(InSize) - 1; + // At the screen's edges, with the world drawn smaller, the jitter can carry the nearest level pixel a + // pixel past the picture. + ivec2 centre = clamp(ivec2(floor(here + jitter)), ivec2(0), last); + + // The Gaussian is normalised so the weights of one frame add up to 1 on average: at full size one + // sample falls in each pixel, at a smaller render fewer, and a frame then adds less than one frame's + // worth to the history. + float falloff = 0.5 / (SAMPLE_SPREAD * SAMPLE_SPREAD * 0.6931472); + float norm = 1.0 / (6.2831853 * SAMPLE_SPREAD * SAMPLE_SPREAD); + + vec3 current = vec3(0.0); + float weight = 0.0; + vec3 sum = vec3(0.0); + vec3 sumSq = vec3(0.0); + vec3 low = vec3(1e9); + vec3 high = vec3(-1e9); + for (int y = -1; y <= 1; y++) { + for (int x = -1; x <= 1; x++) { + ivec2 pixel = clamp(centre + ivec2(x, y), ivec2(0), last); + vec3 c = texelFetch(InSampler, pixel, 0).rgb; + vec2 d = (vec2(pixel) + 0.5 - jitter - here) * scale; + float w = norm * exp2(-falloff * dot(d, d)); + current += c * w; + weight += w; + vec3 ycocg = toYCoCg(c); + sum += ycocg; + sumSq += ycocg * ycocg; + low = min(low, ycocg); + high = max(high, ycocg); + } + } + current /= max(weight, 1e-6); + + // Fetches: 9 level pixels, 5 depths, 5 history reads. + // The nearest surface among the centre and its diagonal neighbours (reversed depth: the largest). + float depth = texelFetch(DepthSampler, centre, 0).r; + depth = max(depth, texelFetch(DepthSampler, clamp(centre + ivec2(-1, -1), ivec2(0), last), 0).r); + depth = max(depth, texelFetch(DepthSampler, clamp(centre + ivec2(1, -1), ivec2(0), last), 0).r); + depth = max(depth, texelFetch(DepthSampler, clamp(centre + ivec2(-1, 1), ivec2(0), last), 0).r); + depth = max(depth, texelFetch(DepthSampler, clamp(centre + ivec2(1, 1), ivec2(0), last), 0).r); + // The sky (depth 0) is taken as very far away, which keeps the maths finite: it moves only as the + // camera turns. + vec3 position = etnaPosition(texCoord, max(depth, 1e-6)); + vec4 previous = PrevViewProj * vec4(position + Motion.xyz, 1.0); + vec2 previousUv = previous.xy / previous.w; + + float share = mix(SHARE_NEAR, SHARE_FAR, 1.0 - exp2(-SHARE_FALLOFF * length(position))); + // The history's weight at which this frame, with an average frame's weight of 1, gets that share. + float cap = 1.0 / share - 1.0; + + bool reusable = Motion.w > 0.5 && previous.w > 0.0 + && all(greaterThanEqual(previousUv, vec2(0.0))) && all(lessThanEqual(previousUv, vec2(1.0))); + vec3 result = current; + float total = weight; + if (reusable) { + float between; + vec4 history = historyAt(previousUv, between); + float historyWeight = clamp(history.a, 0.0, cap) * (1.0 - BETWEEN_PIXELS_LOSS * between); + + // Clip the history into this frame's colour box, along the line to this frame's own colour (kept + // inside the box; Pedersen clips towards the box's centre, this frame's colour is sharper and is + // the box's centre on average), so a colour pulled in keeps its hue as far as it can. + vec3 mean = sum / 9.0; + vec3 deviation = sqrt(max(sumSq / 9.0 - mean * mean, 0.0)); + float boxSize = BOX_SIZE * max(scale.x, 1.0); + vec3 boxLow = max(mean - boxSize * deviation, low); + vec3 boxHigh = min(mean + boxSize * deviation, high); + vec3 target = clamp(toYCoCg(current), boxLow, boxHigh); + vec3 offset = toYCoCg(max(history.rgb, 0.0)) - target; + vec3 room = mix(target - boxLow, boxHigh - target, step(0.0, offset)) + 1e-5; + vec3 reach = abs(offset) / room; + float outside = max(reach.x, max(reach.y, reach.z)); + float pull = mix(1.0, max(outside, 1.0), min(weight / FULL_CLIP_WEIGHT, 1.0)); + vec3 clipped = fromYCoCg(target + offset / pull); + + historyWeight /= 1.0 + CLIP_FORGET * max(outside - 1.0, 0.0); + // Each side counts less the brighter it is (Karis 2014): the picture holds more than white where + // the sun glints on water or glitter sparkles, and one such pixel in a single frame would otherwise + // flash through the average for many frames. + float historyBlend = historyWeight / (1.0 + dot(clipped, ETNA_LUMA)); + float currentBlend = weight / (1.0 + dot(current, ETNA_LUMA)); + result = (clipped * historyBlend + current * currentBlend) / (historyBlend + currentBlend); + total = historyWeight + weight; + } + fragColor = vec4(max(result, 0.0), total); } diff --git a/tools/gen_post_effects.py b/tools/gen_post_effects.py index 450e101..8e7f87e 100644 --- a/tools/gen_post_effects.py +++ b/tools/gen_post_effects.py @@ -54,6 +54,19 @@ HISTORY_READ = "history0" HISTORY_WRITE = "history1" +# Render scale (EtnaScale.java has both): Minecraft's full-size picture in the scaled world chain, and the size +# that makes an Etna target as large as the screen. +SCREEN = "etna:screen" +SCREEN_SIZED = 16384 +# A fixed size is written as this plus the size for a 1920x1080 screen, and PostChainMixin scales it to the size +# the chain runs at (EtnaScale.REFERENCE_SIZED). Without the offset a size that happened to equal the chain's own +# (960 wide on a 1280-wide picture drawn at 75%) couldn't be told from a target given no size at all. +REFERENCE_SIZED = 20000 + + +def sized(w, h): + return {"width": REFERENCE_SIZED + w, "height": REFERENCE_SIZED + h} + # The light shafts' lit share and ray length, at half the screen's size (sized for 1920x1080 and scaled, as # the bloom levels are). SHAFTS_SIZE = (960, 540) @@ -80,8 +93,7 @@ def inp(sampler, target, bilinear=False, depth=False): def shafts_target(): - w, h = SHAFTS_SIZE - return {"width": w, "height": h} + return sized(*SHAFTS_SIZE) def water_ssr_target(): @@ -107,16 +119,22 @@ def world_light_pass(output): inp("WaterSsr", "water_ssr")], output, settings=True) -def copy_pass(source): +def copy_pass(source, output=MAIN): # The last world pass, which also draws the fireflies and motes: their block is EtnaMotes.java's own # buffer, which PostPassMixin swaps in for the placeholder, and main's depth hides them behind surfaces. - return pas("etna:post/copy_dither", [inp("In", source), inp("Depth", MAIN, depth=True)], MAIN, motes=True) + return pas("etna:post/copy_dither", [inp("In", source), inp("Depth", MAIN, depth=True)], output, motes=True) -def world(): +def world(scaled=False): + # With a render scale (EtnaScale) the chain runs on the smaller picture as main, keeps the anti-aliasing history + # at the screen's own size (PostChainMixin makes a SCREEN_SIZED target that size) and the last pass writes + # Minecraft's full-size picture, which the chain calls SCREEN. + history = {"persistent": True} + if scaled: + history.update({"width": SCREEN_SIZED, "height": SCREEN_SIZED}) return { "targets": {"shafts": shafts_target(), "water_ssr": water_ssr_target(), "air": {}, - HISTORY_READ: {"persistent": True}, HISTORY_WRITE: {"persistent": True}}, + HISTORY_READ: dict(history), HISTORY_WRITE: dict(history)}, "passes": [ shafts_pass(), water_ssr_pass(), @@ -125,11 +143,15 @@ def world(): pas("etna:post/taa", [inp("In", "air"), inp("History", HISTORY_READ, bilinear=True), inp("Depth", MAIN, depth=True)], HISTORY_WRITE, settings=True), - copy_pass(HISTORY_WRITE), + copy_pass(HISTORY_WRITE, SCREEN if scaled else MAIN), ], } +def world_scaled(): + return world(scaled=True) + + def world_plain(): return { "targets": {"shafts": shafts_target(), "water_ssr": water_ssr_target(), "lit": {}}, @@ -145,21 +167,22 @@ def world_plain(): def final(): targets = {} for i, (w, h) in enumerate(BLOOM_SIZES): - targets[f"bloom{i}"] = {"width": w, "height": h} + targets[f"bloom{i}"] = sized(w, h) if i < len(BLOOM_SIZES) - 1: - targets[f"up{i}"] = {"width": w, "height": h} + targets[f"up{i}"] = sized(w, h) for i, (w, h) in enumerate(GLOW_SIZES): - targets[f"glow{i}"] = {"width": w, "height": h} - w, h = GLOW_SIZES[0] - targets["glow"] = {"width": w, "height": h} + targets[f"glow{i}"] = sized(w, h) + targets["glow"] = sized(*GLOW_SIZES[0]) # 8 bits per channel (PostChainMixin knows this name). targets["graded"] = {} last = len(BLOOM_SIZES) - 1 # The bloom fog reads main's depth: the final chain runs after the hand, whose depth vanilla merges into - # the level's (integrate_depth), so it holds the world's distances with the hand in front. - passes = [pas("etna:post/bloom_prefilter", [inp("In", MAIN, bilinear=True), inp("Depth", MAIN, depth=True)], "bloom0", - settings=True)] + # the level's (integrate_depth), so it holds the world's distances with the hand in front. With a render + # scale the level's depth is the smaller picture's (EtnaScale.LevelDepth stands in for "Depth") and main's + # own holds only the hand: "HandDepth" (PostPassMixin leaves it alone) masks the hand out. + passes = [pas("etna:post/bloom_prefilter", [inp("In", MAIN, bilinear=True), inp("Depth", MAIN, depth=True), + inp("HandDepth", MAIN, depth=True)], "bloom0", settings=True)] for i in range(1, last + 1): passes.append(pas("etna:post/bloom_down", [inp("In", f"bloom{i - 1}", bilinear=True)], f"bloom{i}")) for i in range(last - 1, -1, -1): @@ -181,7 +204,8 @@ def final(): OUT.mkdir(parents=True, exist_ok=True) for old in OUT.glob("*.json"): old.unlink() -for name, build in (("etna", final), ("etna_world", world), ("etna_world_plain", world_plain)): +for name, build in (("etna", final), ("etna_world", world), ("etna_world_scaled", world_scaled), + ("etna_world_plain", world_plain)): path = OUT / f"{name}.json" path.write_text(json.dumps(build(), indent=4) + "\n") print("wrote", path) diff --git a/tools/validate_shaders.py b/tools/validate_shaders.py index 621b084..b82fac8 100644 --- a/tools/validate_shaders.py +++ b/tools/validate_shaders.py @@ -108,6 +108,10 @@ def vanilla_dir(): + if len(sys.argv) > 1 and sys.argv[1].startswith("-"): + # Not a folder: "--help" once became one, with Mojang's shaders downloaded into it. + print(__doc__) + sys.exit(0 if sys.argv[1] in ("-h", "--help") else 2) d = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "build/vanilla-assets" inc = d / "minecraft/shaders/include" inc.mkdir(parents=True, exist_ok=True) From 26034acb665abe92adff6268fa56be11454699aa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 02:06:52 +0000 Subject: [PATCH 06/24] Etna 0.9.0: no code from other shader packs With the anti-aliasing and the shafts' order rewritten here and 0.8.1's own shadow warp and water Fresnel, nothing ported from Photon Shaders is left: LICENSE-PHOTON.txt and its jar entry go, the CREDITS rows and the Modrinth note go (the techniques are credited instead), LICENSE is plain MIT, and the README, fabric.mod.json and the changelog say so. Version 0.9.0. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01F7MajtknSApr1T68EGCPtx --- CHANGELOG.md | 8 ++++++++ CREDITS.md | 25 +++++++++++++++---------- LICENSE | 2 -- LICENSE-PHOTON.txt | 19 ------------------- README.md | 12 +++++------- build.gradle | 2 +- gradle.properties | 2 +- src/main/resources/fabric.mod.json | 12 +++--------- 8 files changed, 33 insertions(+), 49 deletions(-) delete mode 100644 LICENSE-PHOTON.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 883936b..c9aee5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ Every release is for Minecraft 26.4-snapshot-3 with Fabric. Versions: the middle number goes up for new features, the last one for fixes. Jars are on the [Releases](https://github.com/Atomsk-debug/Etna/releases) page. +## 0.9.0 (unreleased) + +Faster with World Resolution, and Etna's own anti-aliasing. + +* **World Resolution (new, General page, 50 to 100%).** Draws the world at fewer pixels and builds the full-size picture back up in Etna's anti-aliasing, which moves the camera by a different fraction of a pixel every frame and puts the frames together at full size. Most of Etna's cost is per pixel, so at 75% the world shades a little over half the pixels. Textures keep their full-size detail, and the hand, the menus and the HUD stay sharp at full size. Needs anti-aliasing, which every Quality but Low has. The benchmark has a step for it. +* **New anti-aliasing, Etna's own.** Steadier and sharper than 0.8's: in Etna's test bench about a third less flicker and error, edges closer to a supersampled picture, and less trailing behind moving things. Glints and sparkles brighter than white no longer flash through it. +* **No code from other shader packs any more.** The anti-aliasing and the light shafts' pixel order were the last parts from Photon Shaders; with 0.8.1's own shadow warp and water Fresnel, Etna is now entirely its own code (and Minecraft's shaders it builds on), so it can go on Modrinth and CurseForge. `LICENSE-PHOTON.txt` is gone. + ## 0.8.1 (unreleased) Fixes for 0.8.0's water, and shadows that soften with distance. diff --git a/CREDITS.md b/CREDITS.md index 87762dd..751645b 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -1,26 +1,31 @@ # Credits -Etna is original code under the MIT licence (`LICENSE`), with these exceptions: the temporal anti-aliasing -pass is ported from another shader pack, under that pack's own licence; the sky's star hash is David Hoskins's, under the MIT licence; and the terrain and -cloud shaders are based on Minecraft's own, which Etna's licence doesn't cover. +Etna is original code under the MIT licence (`LICENSE`), with these exceptions: the sky's star hash is David +Hoskins's, under the MIT licence; and the terrain and cloud shaders are based on Minecraft's own, which Etna's +licence doesn't cover. No code from other shader packs is used. ## Code ported from other projects | Etna file | Source | Licence | |---|---|---| -| `src/main/resources/assets/etna/shaders/post/taa.fsh` | [Photon Shaders](https://github.com/sixthsurge/photon) by SixthSurge: `program/c4_taa_exposure.fsh` (TAA, neighbourhood and variance clipping, offcenter rejection) and `include/utility/bicubic.glsl` (fast Catmull-Rom history filter), adapted to Minecraft's reversed depth buffer and Etna's camera matrices | Photon Shaders License, see `LICENSE-PHOTON.txt` | | `src/main/resources/assets/etna/shaders/core/sky.fsh`: `hash33` (where the stars sit, how bright they are, their colour) | "Hash without Sine" by David Hoskins ([shadertoy.com/view/4djSRW](https://www.shadertoy.com/view/4djSRW)), `hash33`, unchanged | MIT, Copyright (c) 2014 David Hoskins, licence text as in `LICENSE` | | `src/main/resources/assets/etna/shaders/core/sky.vsh`: `hash41` (when and where a shooting star flares, which way it falls) | "Hash without Sine" by David Hoskins ([shadertoy.com/view/4djSRW](https://www.shadertoy.com/view/4djSRW)), `hash41`, unchanged | MIT, Copyright (c) 2014 David Hoskins, licence text as in `LICENSE` | -| `src/main/resources/assets/etna/shaders/post/shafts.fsh`: `SUBPIXEL` (the order in which the light shafts' half-resolution pixels take turns) | [Photon Shaders](https://github.com/sixthsurge/photon) by SixthSurge: `checkerboard.glsl`, the same 2×2 order | Photon Shaders License, see `LICENSE-PHOTON.txt` | | `src/main/resources/assets/etna/shaders/core/terrain.vsh`, `terrain.fsh`, `clouds.vsh`, `clouds.fsh`, `weather.vsh`, `weather.fsh` | Minecraft's vanilla `terrain`, `clouds` and `particle` core shaders, which Etna replaces while it is on and extends (`weather` is made from `particle`, which vanilla draws rain and snow with) | Based on Minecraft's vanilla shader, (c) Mojang AB, not covered by Etna's MIT licence | -The Photon Shaders License allows redistributing modified portions, except on platforms that pay creators -(Modrinth, CurseForge) without SixthSurge's written permission, and never for money. Builds of Etna that -contain these parts fall under those terms; publishing Etna on Modrinth or CurseForge needs that permission -first, or original replacements for them. - ## Techniques Etna's own code follows +* Temporal anti-aliasing and upsampling (`taa.fsh`, since 0.9.0 Etna's own; before, a port from Photon Shaders): + each frame rebuilt from the 3x3 level pixels around the screen pixel, weighted by their jittered distance to its + centre (Yang, Nehab, Sander, Sitthi-amorn, Lawrence and Hoppe, "Amortized Supersampling", 2009; Unreal Engine's + TAAU), the history's weight kept in alpha; nearest-depth reprojection and YCoCg (Brian Karis, "High Quality + Temporal Supersampling", SIGGRAPH 2014), a 1 / (1 + luma) weighted blend (Karis 2014), the variance box (Marco + Salvi, "An Excursion in Temporal Supersampling", GDC 2016), clipping along a line (Lasse Jon Fuglsang Pedersen, + "Temporal Reprojection Anti-Aliasing in INSIDE", GDC 2016), Catmull-Rom history from five bilinear reads (Jorge + Jimenez, "Filmic SMAA", SIGGRAPH 2016). Jitter steps for a smaller render after AMD FidelityFX Super Resolution 2's + documentation (MIT). +* Light shafts' half-resolution order (`shafts.fsh`): the 2x2 cells of Bryce Bayer's ordered-dither matrix + ("An optimum method for two-level rendition of continuous-tone pictures", 1973). + * Ambient occlusion (`world_light.fsh`): disc sampling in the surface plane with an elevation-angle weight, after Photon's SSAO (`include/lighting/ao/ssao.glsl`). * Bloom (`bloom_*.fsh`): 13-tap downsampling filter and tent upsampling from "Next Generation diff --git a/LICENSE b/LICENSE index 3bfd6b0..6a100ca 100644 --- a/LICENSE +++ b/LICENSE @@ -19,5 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -Some files are under other licences: see CREDITS.md (Photon Shaders License, Mojang). diff --git a/LICENSE-PHOTON.txt b/LICENSE-PHOTON.txt deleted file mode 100644 index f84b23c..0000000 --- a/LICENSE-PHOTON.txt +++ /dev/null @@ -1,19 +0,0 @@ -Photon Shaders License Agreement - -Copyright © 2021-2025 Benjamin Stott ("SixthSurge") - -All rights reserved unless otherwise explicitly stated. - -Any person obtaining a copy of this software package ("Photon Shaders") may: -- Use Photon Shaders, including for commercially published screenshots and videos. -- Modify Photon Shaders for personal use. -- Examine and learn from Photon Shaders's source code. -- Redistribute modified or unmodified portions of Photon Shaders with the following exceptions: - - You may not redistribute Photon Shaders or derivative works on platforms that provide direct or indirect monetary compensation to creators (e.g. Modrinth, CurseForge) without my explicit written permission. - - You may not demand money in exchange for copies of Photon Shaders or derivative works without my explicit written permission. -- Redistribute Photon Shaders as part of a (commercial) Modpack. - -This license document is to be included in all substantial copies of Photon Shaders. - -PHOTON SHADERS IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH PHOTON SHADERS OR THE USE OR OTHER DEALINGS IN PHOTON SHADERS. - diff --git a/README.md b/README.md index 1a7261d..f0eea1f 100644 --- a/README.md +++ b/README.md @@ -106,9 +106,9 @@ Lower presets are faster; Low has no shadows and no anti-aliasing. See [docs/ben Yes. Etna replaces none of the game's files: with F7 off it draws with the game's own shaders, textures and models. On a Mac, Half resolution applies only when Etna is on as the game starts. -**Why isn't it on Modrinth or CurseForge?** -A few parts come from Photon Shaders, whose licence doesn't allow those sites without its author's permission. -Releases are here on GitHub. +**Is it on Modrinth or CurseForge?** +Not yet. Releases are here on GitHub. Since 0.9.0 Etna has no code from other shader packs, so nothing stands in +the way. **Something looks wrong.** Press F7: if it goes away, it's Etna. Note where and when it happens (time of day, near or far, which blocks) and @@ -120,8 +120,6 @@ No. Etna works on its own; Vesuvio makes the game, and Etna's shadows, faster. ## Credits and licence Etna's own code is under the MIT licence, see [LICENSE](LICENSE). Its terrain and cloud shaders are based on -Minecraft's vanilla shaders (© Mojang AB), which Etna's licence doesn't cover. A few small parts are ported from -[Photon Shaders](https://github.com/sixthsurge/photon) by SixthSurge (temporal anti-aliasing, the order of -the light shafts' half-resolution pixels) and stay under the Photon Shaders License (`LICENSE-PHOTON.txt`). -Complementary Reimagined inspired the look; none of its code is used. Everything taken from other projects and +Minecraft's vanilla shaders (© Mojang AB), which Etna's licence doesn't cover. No code from other shader packs +is used. Complementary Reimagined inspired the look; none of its code is used. Everything taken from other projects and every technique Etna follows is listed in [CREDITS.md](CREDITS.md). diff --git a/build.gradle b/build.gradle index f1e5c40..d0d15ae 100644 --- a/build.gradle +++ b/build.gradle @@ -32,7 +32,7 @@ jar { def projectName = project.name inputs.property "projectName", projectName - from(["LICENSE", "LICENSE-PHOTON.txt", "CREDITS.md"]) { + from(["LICENSE", "CREDITS.md"]) { rename { "${it}_$projectName" } } } diff --git a/gradle.properties b/gradle.properties index 9b2d687..cbaa41a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,7 +12,7 @@ loom_version=1.18-SNAPSHOT # Mod Properties # Bump on every build handed out for testing: patch for fixes, minor for new features. The jar and the # version shown in the game's mod list both become +mc. -mod_version=0.8.1 +mod_version=0.9.0 group=it.cosimo.etna # Dependencies diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index dac3105..98b9340 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -3,14 +3,11 @@ "id": "etna", "version": "${version}", "name": "Etna Shaders", - "description": "Shaders for Minecraft's native Vulkan renderer, built only on the game's own render backend. Temporal anti-aliasing, shadow map distortion and the water's Fresnel function are ported from Photon Shaders by SixthSurge (see CREDITS.md).", + "description": "Shaders for Minecraft's native Vulkan renderer, built only on the game's own render backend. Original code; the techniques it follows are credited in CREDITS.md.", "authors": [ "Cosimo" ], - "license": [ - "MIT", - "LicenseRef-Photon-Shaders-License" - ], + "license": "MIT", "environment": "client", "entrypoints": { "client": [ @@ -28,8 +25,5 @@ }, "breaks": { "vulkanmod": "*" - }, - "contributors": [ - "SixthSurge (Photon Shaders: temporal anti-aliasing, shadow distortion, water Fresnel)" - ] + } } From 34805a1d556bf396f1dda2b0f5cbde06531b806d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 02:14:18 +0000 Subject: [PATCH 07/24] Etna 0.8.1: shadows on water from the water under the surface The water's top took the sharp shadow of its own point as the body's light, while the bottom seen through it had its own shadow, shifted by the depth; the two copies crossed each other out. The body's sun share is now the shadow at four points down the refracted view, placed at equal shares of the light scattered back above the bottom (green extinction down along the sun and back up along the view), each a bilinear 2x2 lookup (new shadowSoftAt). Clear shallow water shows the bottom's shadow; murky or deep water a soft shadow in the water. Water whose bottom the shadow map can't show (wide shade, a roof) went back to the plain, far more opaque look in the shadow's shape. A fourth ring at 8 blocks looks for the bottom further round, and where none is found the depth model keeps going with 4 blocks of water. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Xzo7zbTpyU7a3y3XQNbw1n --- CHANGELOG.md | 1 + .../assets/etna/shaders/core/terrain.fsh | 5 + .../etna/shaders/include/terrain_hooks.glsl | 4 + .../etna/shaders/include/terrain_shadow.glsl | 28 +++++ .../etna/shaders/include/terrain_water.glsl | 100 ++++++++++++++---- 5 files changed, 116 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 883936b..754675e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Fixes for 0.8.0's water, and shadows that soften with distance. * Far shadows fade out instead of stopping at a straight line about 180 blocks away when looking towards or away from a low sun, and ground more than 320 blocks below the camera along the light (far away under a low sun, or seen from very high up) no longer turns dark. * **Reflections keep their shapes.** The shore, trees and hills mirrored in the water came out as scattered dark dots. Each reflected ray now takes the same fixed steps every frame and counts as a hit only where it really runs into something, rays that find nothing are averaged in instead of dropped, and the mirror image follows a calmer copy of the waves, so it wobbles as a whole instead of breaking up. +* **Shadows on water make sense.** The water's own colour now takes the shade of the water under the surface, where its light comes from: on clear shallow water a tree's shadow is the one on the bottom, seen through the water, and on deep or murky water a soft shadow in the water itself. Before, the surface carried a second, sharp copy of each shadow next to the one on the bottom, and the two crossed each other out in strange shapes. Water in a wide shade (under a cliff, a big tree or a roof) also keeps the clarity of the water around it instead of turning murky in the shadow's shape. * **Foam, redone.** Only a thin, faint, bubbly line where still water meets the shore, in a pale colour that no longer glows. Shallow water stays clear (0.8.0 foamed every floor just under the surface into a milky haze), and water flowing down a slope has no foam. ## 0.8.0 (2026-10-10), "Enchanted" diff --git a/src/main/resources/assets/etna/shaders/core/terrain.fsh b/src/main/resources/assets/etna/shaders/core/terrain.fsh index ba7fe6c..d7200b8 100644 --- a/src/main/resources/assets/etna/shaders/core/terrain.fsh +++ b/src/main/resources/assets/etna/shaders/core/terrain.fsh @@ -609,7 +609,12 @@ vec3 shadeTerrain(vec3 color, vec3 normal, SurfaceDetail detail, float kind) { // Faces turned away from the sun get no sun whatever the map says (every use of the shadow is // multiplied by the face's own angle to the sun), so they skip the lookup. if (EtnaLook2.w > 0.5 && ETNA_LIGHT_STRENGTH * etnaSunOpen() > 0.01 && (thin || dot(normal, ETNA_LIGHT_DIR) > 0.0)) { + #ifdef ETNA_TRANSLUCENT_LAYER + // Water's colour is light scattered back from under its surface, shaded down there (waterBodyShadow). + shadow = etnaMaterial > 2.5 ? waterBodyShadow(normal) : shadowVisibility(normal, thin); + #else shadow = shadowVisibility(normal, thin); + #endif } // Clouds drifting over the sun, with or without the shadow map. if (ETNA_LIGHT_STRENGTH * etnaSunOpen() > 0.01 && (thin || dot(normal, ETNA_LIGHT_DIR) > 0.0)) { diff --git a/src/main/resources/assets/etna/shaders/include/terrain_hooks.glsl b/src/main/resources/assets/etna/shaders/include/terrain_hooks.glsl index 31c8304..0fb3da2 100644 --- a/src/main/resources/assets/etna/shaders/include/terrain_hooks.glsl +++ b/src/main/resources/assets/etna/shaders/include/terrain_hooks.glsl @@ -8,6 +8,7 @@ float shadowVisibility(vec3 normal, bool thin); // shadeTerrain: filtered shadow on this fragment, 1 lit .. 0 shaded float shadowAt(vec3 position); // sunGlint, waterFromBelow, etnaWaterPrepare: unfiltered lookup at a camera-relative point float shadowAtSample(vec3 position, float pick); // underwaterShafts: the same, one map per jittered sample +float shadowSoftAt(vec3 position); // waterBodyShadow: the same, with the four texels round the point blended vec4 shadowSpace(vec3 position); // a camera-relative point in the map: xy uv, z expected depth, w how far out (1 at the edge) float leafBacklight(); // leafGlow: sunlight through the leaves to this one, 0 .. 1 // terrain_clouds.glsl: the clouds' shadows, in the same draws. @@ -24,6 +25,9 @@ vec3 etnaSkyShare(vec3 skyPart); // shadeTerrain: the sky's share of vani void etnaFinalSkyShare(vec3 skyPart); // shadeTerrain: the sky's share as skyLighting gets multiplied with it float etnaShadeOpen(); // etnaShadeWeight (terrain_dark.glsl): how open to the light from above vec4 shadeWater(vec4 color, vec3 normal); // main, for water (etnaMaterial 3) +#if defined(ETNA_SHADOWS) && defined(ETNA_TRANSLUCENT_LAYER) +float waterBodyShadow(vec3 normal); // shadeTerrain, water: the sun's share in the light the water scatters back +#endif vec3 etnaMediumFog(vec3 color); // main, after the distance haze and before vanilla's fog float etnaEnvironmentalFog(); // calculateFinalColor: factor on vanilla's environmental fog diff --git a/src/main/resources/assets/etna/shaders/include/terrain_shadow.glsl b/src/main/resources/assets/etna/shaders/include/terrain_shadow.glsl index b18ef43..173854e 100644 --- a/src/main/resources/assets/etna/shaders/include/terrain_shadow.glsl +++ b/src/main/resources/assets/etna/shaders/include/terrain_shadow.glsl @@ -365,6 +365,34 @@ float shadowAt(vec3 position) { return mix(lit, 1.0, etnaCasterFade(position)); } +// The test for a point from shadowSpace (or the same for the second map) with the four texels round it +// blended bilinearly, so it slides smoothly as the point moves instead of stepping a texel at a time, and +// fading out towards the map's edge as shadowVisibility does: for averages of a few points (the light in +// the water under its surface, waterBodyShadow), where single texels would show as steps. +float etnaShadowSoft(sampler2D map, vec4 point) { + if (point.w >= 1.0 || point.z <= 0.0) { + return 1.0; + } + vec2 q = point.xy / max(EtnaShadowInfo.x, 1e-6); + vec2 corner = floor(q - 0.5) + 1.0; + vec2 f = q - (corner - 0.5); + // Reversed depth: open where the map holds nothing closer to the light than the point, by the bias. + vec4 open = step(etnaShadowQuad(map, corner), vec4(point.z + EtnaShadowInfo.z * 0.05)); + float lit = dot(open, vec4((1.0 - f.x) * f.y, f.x * f.y, f.x * (1.0 - f.y), (1.0 - f.x) * (1.0 - f.y))); + return mix(lit, 1.0, smoothstep(0.85, 1.0, point.w)); +} + +// etnaShadowSoft for a camera-relative point, from the two maps while they fade, and as light as +// shadowVisibility makes shadows under a low sun and towards the end of the casters. +float shadowSoftAt(vec3 position) { + float lit = etnaShadowSoft(EtnaShadowSampler, shadowSpace(position)); + if (EtnaShadowFade.x > 0.0) { + vec4 clip = EtnaShadowMat2 * vec4(position, 1.0); + lit = mix(lit, etnaShadowSoft(EtnaShadowSampler2, etnaShadowMapPoint(clip.xyz / clip.w)), EtnaShadowFade.x); + } + return mix(lit, 1.0, max(etnaLowSunFill(), etnaCasterFade(position))); +} + // shadowAt for one of many jittered samples that are averaged (the light shafts under water): it reads // one map only, the second when `pick` (0 .. 1, a different value for each sample) is below the fade's // weight, so the average mixes the two maps as shadowAt does, for one read a sample. diff --git a/src/main/resources/assets/etna/shaders/include/terrain_water.glsl b/src/main/resources/assets/etna/shaders/include/terrain_water.glsl index d25b583..3e52c1f 100644 --- a/src/main/resources/assets/etna/shaders/include/terrain_water.glsl +++ b/src/main/resources/assets/etna/shaders/include/terrain_water.glsl @@ -481,26 +481,28 @@ vec4 etnaFitAlpha(vec3 rgb, float alpha) { // Where the point itself lies in the shade of something over the water (a tree, a cliff, the bank on the // sun's side, leaves), the map holds that instead of the bottom, but around the shade it still holds the -// bottom: these points on the water's plane, in blocks (x, z) with their weight, stand in for it. Twelve -// in three rings: 0.8 blocks away, 2 blocks away on the diagonals and 4 blocks away, nearer ones counting -// more. Fixed in the world, so the result never crawls as the camera moves. -const vec3 THICKNESS_RING[12] = vec3[12]( +// bottom: these points on the water's plane, in blocks (x, z) with their weight, stand in for it. Sixteen +// in four rings: 0.8 blocks away, 2 blocks away on the diagonals, 4 blocks away and 8 blocks away on the +// diagonals, nearer ones counting more. Fixed in the world, so the result never crawls as the camera moves. +const vec3 THICKNESS_RING[16] = vec3[16]( vec3(0.8, 0.0, 1.0), vec3(-0.8, 0.0, 1.0), vec3(0.0, 0.8, 1.0), vec3(0.0, -0.8, 1.0), vec3(1.414, 1.414, 0.7), vec3(-1.414, 1.414, 0.7), vec3(1.414, -1.414, 0.7), vec3(-1.414, -1.414, 0.7), - vec3(4.0, 0.0, 0.5), vec3(-4.0, 0.0, 0.5), vec3(0.0, 4.0, 0.5), vec3(0.0, -4.0, 0.5) + vec3(4.0, 0.0, 0.5), vec3(-4.0, 0.0, 0.5), vec3(0.0, 4.0, 0.5), vec3(0.0, -4.0, 0.5), + vec3(5.657, 5.657, 0.35), vec3(-5.657, 5.657, 0.35), vec3(5.657, -5.657, 0.35), vec3(-5.657, -5.657, 0.35) ); // Blocks of water under this point of the surface, along the light, read from the shadow map: it holds -// only opaque terrain, so under open water it holds the bottom. known says how far that can be trusted, -// 0 .. 1: nothing without the map or outside it. Where something opaque hangs over the water the map -// holds that instead, and the bottom around the shade (THICKNESS_RING) stands in: dappled shade under +// only opaque terrain, so under open water it holds the bottom. found says how much of that was found, +// 0 .. 1; trust how far the map can be gone by at all: 0 without the map or outside it, fading out towards +// the map's edge, so no ring shows where it ends, and as the light fades at dusk or in rain, so the water +// doesn't change at once when the map stops being drawn. Where something opaque hangs over the water the +// map holds that instead, and the bottom around the shade (THICKNESS_RING) stands in: dappled shade under // leaves and narrow shade along a bank keep the clear water around them, and only the middle of a wide -// shade, with no bottom in sight, goes back to the surface as it was. known also fades out towards the -// map's edge, so no ring shows where it ends, and as the light fades at dusk or in rain, so the water -// doesn't change at once when the map stops being drawn. The four texels around the point are weighed -// bilinearly, so shorelines don't step texel by texel. -float waterThickness(vec3 surfacePos, out float known) { - known = 0.0; +// shade, with no bottom in sight, is left without it (waterDepthBelow). The four texels around the point +// are weighed bilinearly, so shorelines don't step texel by texel. +float waterThickness(vec3 surfacePos, out float found, out float trust) { + found = 0.0; + trust = 0.0; if (EtnaLook2.w < 0.5) { return 0.0; } @@ -530,13 +532,13 @@ float waterThickness(vec3 surfacePos, out float known) { weight += w; } } - float found = weight; + found = weight; if (weight < 0.999) { vec3 alongX = EtnaShadowMat[0].xyz; vec3 alongZ = EtnaShadowMat[2].xyz; float ringSum = 0.0; float ringWeight = 0.0; - for (int i = 0; i < 12; i++) { + for (int i = 0; i < 16; i++) { vec3 tap = THICKNESS_RING[i]; vec4 p = etnaShadowMapPoint(clip + alongX * tap.x + alongZ * tap.y); if (p.w < 1.0) { @@ -554,10 +556,64 @@ float waterThickness(vec3 surfacePos, out float known) { sum += share * ringSum / max(ringWeight, 1e-4); found = weight + share; } - known = found * (1.0 - smoothstep(0.85, 1.0, map.w)) * smoothstep(0.002, 0.03, ETNA_LIGHT_STRENGTH); + trust = (1.0 - smoothstep(0.85, 1.0, map.w)) * smoothstep(0.002, 0.03, ETNA_LIGHT_STRENGTH); return sum / max(found, 1e-4); } +// Blocks of water taken to lie under a point whose bottom the map can't show, in the middle of a wide shade +// or under a roof: about a lake's depth. Before 0.8.1 such water went back to the plain look of water without +// depth, which lets far less through, and shade on a lake turned it murky in the shadow's shape. +const float WATER_UNSEEN_DEPTH = 4.0; + +// Worked out once per fragment by waterDepthBelow, for waterBodyShadow and waterFromAbove. +float etnaWaterBelow = -1.0; +float etnaWaterTrust = 0.0; + +// Blocks of water straight down from this point of the surface, and (trust) how far the depth can be gone +// by at all (waterThickness). +float waterDepthBelow(out float trust) { + if (etnaWaterBelow < 0.0) { + float found; + float alongLight = waterThickness(etnaViewPos, found, etnaWaterTrust); + etnaWaterBelow = mix(WATER_UNSEEN_DEPTH, alongLight * max(ETNA_LIGHT_DIR.y, 0.05), found); + } + trust = etnaWaterTrust; + return etnaWaterBelow; +} + +// The sun's share in the light the water scatters back up from under its surface, which is what its own +// colour is (the body in waterFromAbove). That light comes from all the way down to the bottom, more of it +// from near the surface the murkier the water, so a tree's shadow on clear shallow water is the shadow on +// the bottom seen through it, and on murky or deep water a soft shadow in the water itself. Before 0.8.1 +// the surface took the sharp shadow of its own point, a second copy of every shadow next to the one on +// the bottom (shifted from it by the depth, the more the lower the sun), and the two crossed each other out +// in strange shapes. Four points down the view's way into the water, each at an equal share of the light +// scattered back above the bottom, each the bilinear blend of four texels (shadowSoftAt). Seen from +// below, or past the map, the surface's own shadow as before. +float waterBodyShadow(vec3 normal) { + if (normal.y < 0.8 || cameraInAir() < 0.5) { + return shadowVisibility(normal, false); + } + float trust; + float below = waterDepthBelow(trust); + if (trust <= 0.0) { + return shadowVisibility(normal, false); + } + vec3 down = refract(normalize(etnaViewPos), vec3(0.0, 1.0, 0.0), 1.0 / WATER_IOR); + float cosDown = max(-down.y, 0.1); + // Light scattered back from z blocks down went z / sin(sun) blocks through the water on its way down + // and comes z / cos(view) blocks back up: green's extinction over both, per block of depth. + float perDepth = waterExtinction(EtnaWaterLook.x).g * (1.0 / max(ETNA_LIGHT_DIR.y, 0.1) + 1.0 / cosDown); + float total = 1.0 - exp(-perDepth * below); + float lit = 0.0; + for (int i = 0; i < 4; i++) { + float z = -log(1.0 - (float(i) + 0.5) * 0.25 * total) / perDepth; + lit += shadowSoftAt(etnaViewPos + down * (z / cosDown)); + } + lit *= 0.25; + return trust < 1.0 ? mix(shadowVisibility(normal, false), lit, trust) : lit; +} + // ---- Shoreline foam ----------------------------------------------------------------------------------- // A thin, faint, broken line of foam where still water meets the shore. Minecraft's shores run along the // edges of blocks, so the line is measured from those edges: the shadow map, which holds opaque terrain @@ -677,20 +733,20 @@ vec3 foamLight() { // The water surface seen from above: shallow water clear, deep water the colour of the water, foam along // the shore. vanillaAlpha: the water texture's alpha; lit: the surface as shadeTerrain lit it, whose // brightness (shade of trees and cliffs, time of day) the water's body takes, with the hue of the water's -// own tint. fresnel and reflection: the sky's reflection, glint: the sun's, with the glitter. p and t: +// own tint (lit took the shadow in the water under the surface, waterBodyShadow). fresnel and reflection: the sky's reflection, glint: the sun's, with the glitter. p and t: // where (world xz) and when the foam is worked out; level: whether the surface lies flat (no foam on water // flowing down a slope). The blend then gives // behind * (1 - a)(1 - F) + body * (1 - T)(1 - F) + reflection * F + glint, with T what the water lets -// through on the way to the bottom and back up to the eye, and a its average. Where the depth is unknown -// the body is the lit texture's colour and a its alpha: the look water had before it had depth, with what +// through on the way to the bottom and back up to the eye, and a its average. Where the map can't be gone +// by (past it, without shadows, at dusk) the body is the lit texture's colour and a its alpha: the look +// water had before it had depth, with what // it mirrors taken out of what it lets through, so the sky's reflection is always exactly reflection * F // (the water reflections pass, post/water_ssr.fsh, takes that out again where it finds the world instead). vec4 waterFromAbove(float vanillaAlpha, vec3 lit, vec3 viewDir, vec3 reflection, float fresnel, vec3 glint, vec2 p, float t, bool level) { float plainAlpha = 1.0 - (1.0 - vanillaAlpha) * (1.0 - fresnel); vec4 surface = etnaFitAlpha((lit * (vanillaAlpha * (1.0 - fresnel)) + reflection * fresnel + glint) / max(plainAlpha, 0.05), plainAlpha); float known; - float alongLight = waterThickness(etnaViewPos, known); - float vertical = alongLight * max(ETNA_LIGHT_DIR.y, 0.05); + float vertical = waterDepthBelow(known); // Mixed as the blend sees them (colour times alpha), so every hand-over stays smooth. vec3 covered = surface.rgb * surface.a; float outAlpha = surface.a; From 47303a730a40bdaf5a26ae9988d83fbeaf9a8f96 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 02:19:32 +0000 Subject: [PATCH 08/24] Etna 0.8.1: steadier water reflections A ray that only just meets or misses an edge went either way from frame to frame (the jittered picture's edges move by up to half a pixel, the waves move on), and a whole half-resolution texel, spread over its neighbours by the world pass, changed at once: too smooth for the TAA's neighbourhood clip to tell from a real change, so the reflection flickered. The reflections pass now traces from the pixel's unjittered centre and reads depth and colour at the jittered spot that holds each point, and with anti-aliasing on it blends each texel with its own history (SSR_BLEND 0.3, more of this frame while the camera moves), reprojected by the water point's position with PrevViewProj and kept only where last frame held water at that distance. The history is two persistent half- size targets, water_ssr0/1, swapped with the TAA history (PostChainMixin); the plain chain binds the shafts as a placeholder and never reads it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Xzo7zbTpyU7a3y3XQNbw1n --- CHANGELOG.md | 1 + .../it/cosimo/etna/mixin/PostChainMixin.java | 23 ++++- .../assets/etna/post_effect/etna_world.json | 18 +++- .../etna/post_effect/etna_world_plain.json | 4 + .../assets/etna/shaders/post/water_ssr.fsh | 91 ++++++++++++++++--- tools/gen_post_effects.py | 40 +++++--- 6 files changed, 142 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 754675e..0c6a6ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Fixes for 0.8.0's water, and shadows that soften with distance. * Far shadows fade out instead of stopping at a straight line about 180 blocks away when looking towards or away from a low sun, and ground more than 320 blocks below the camera along the light (far away under a low sun, or seen from very high up) no longer turns dark. * **Reflections keep their shapes.** The shore, trees and hills mirrored in the water came out as scattered dark dots. Each reflected ray now takes the same fixed steps every frame and counts as a hit only where it really runs into something, rays that find nothing are averaged in instead of dropped, and the mirror image follows a calmer copy of the waves, so it wobbles as a whole instead of breaking up. +* **Steadier reflections.** Reflections in the water no longer flicker where a reflected edge (a tree, a roof, the shore) only just meets the water's ray: each spot of the water now blends its reflection over the last few frames, and the rays are traced from where the picture really is, not from where the anti-aliasing has moved it this frame. * **Shadows on water make sense.** The water's own colour now takes the shade of the water under the surface, where its light comes from: on clear shallow water a tree's shadow is the one on the bottom, seen through the water, and on deep or murky water a soft shadow in the water itself. Before, the surface carried a second, sharp copy of each shadow next to the one on the bottom, and the two crossed each other out in strange shapes. Water in a wide shade (under a cliff, a big tree or a roof) also keeps the clarity of the water around it instead of turning murky in the shadow's shape. * **Foam, redone.** Only a thin, faint, bubbly line where still water meets the shore, in a pale colour that no longer glows. Shallow water stays clear (0.8.0 foamed every floor just under the surface into a milky haze), and water flowing down a slope has no foam. diff --git a/src/main/java/it/cosimo/etna/mixin/PostChainMixin.java b/src/main/java/it/cosimo/etna/mixin/PostChainMixin.java index aeb9bc5..112455d 100644 --- a/src/main/java/it/cosimo/etna/mixin/PostChainMixin.java +++ b/src/main/java/it/cosimo/etna/mixin/PostChainMixin.java @@ -45,7 +45,8 @@ * * The anti-aliasing history is two persistent targets used in turn: the world chain reads last frame's * result from "history0" and writes this frame's into "history1", and the two textures behind those - * names swap after every frame (EtnaFrame.historySwapped), so the result is never copied. + * names swap after every frame (EtnaFrame.historySwapped), so the result is never copied. The water + * reflections keep their own history in "water_ssr0" and "water_ssr1" the same way. * Vanilla's chains are untouched. */ @Mixin(PostChain.class) @@ -68,6 +69,13 @@ public abstract class PostChainMixin { @Unique private static final Identifier ETNA_HISTORY_WRITE = Identifier.withDefaultNamespace("history1"); + /** The water reflections' own history, kept the same way; gen_post_effects.py knows these names. */ + @Unique + private static final Identifier ETNA_SSR_READ = Identifier.withDefaultNamespace("water_ssr0"); + + @Unique + private static final Identifier ETNA_SSR_WRITE = Identifier.withDefaultNamespace("water_ssr1"); + @Unique private static boolean etna$hdrPass; @@ -193,8 +201,9 @@ public abstract class PostChainMixin { /** * Every other frame the world chain's two history names are given each other's textures: the pass - * that reads "history0" then reads what the last frame wrote into "history1". Only the lookup changes; - * each texture is still made, resized and freed with the chain's other persistent targets. + * that reads "history0" then reads what the last frame wrote into "history1", and the same for the water + * reflections' "water_ssr0" and "water_ssr1". Only the lookup changes; each texture is still made, + * resized and freed with the chain's other persistent targets. */ @ModifyArg( method = "addToFrame", @@ -211,6 +220,12 @@ public abstract class PostChainMixin { if (target.equals(ETNA_HISTORY_READ)) { return ETNA_HISTORY_WRITE; } - return target.equals(ETNA_HISTORY_WRITE) ? ETNA_HISTORY_READ : target; + if (target.equals(ETNA_HISTORY_WRITE)) { + return ETNA_HISTORY_READ; + } + if (target.equals(ETNA_SSR_READ)) { + return ETNA_SSR_WRITE; + } + return target.equals(ETNA_SSR_WRITE) ? ETNA_SSR_READ : target; } } diff --git a/src/main/resources/assets/etna/post_effect/etna_world.json b/src/main/resources/assets/etna/post_effect/etna_world.json index f8803f6..5df4f8f 100644 --- a/src/main/resources/assets/etna/post_effect/etna_world.json +++ b/src/main/resources/assets/etna/post_effect/etna_world.json @@ -4,9 +4,15 @@ "width": 960, "height": 540 }, - "water_ssr": { + "water_ssr0": { "width": 960, - "height": 540 + "height": 540, + "persistent": true + }, + "water_ssr1": { + "width": 960, + "height": 540, + "persistent": true }, "air": {}, "history0": { @@ -72,9 +78,13 @@ "sampler_name": "Depth", "target": "minecraft:main", "use_depth_buffer": true + }, + { + "sampler_name": "SsrHistory", + "target": "water_ssr0" } ], - "output": "water_ssr", + "output": "water_ssr1", "uniforms": { "EtnaSettings": [ { @@ -121,7 +131,7 @@ }, { "sampler_name": "WaterSsr", - "target": "water_ssr" + "target": "water_ssr1" } ], "output": "air", diff --git a/src/main/resources/assets/etna/post_effect/etna_world_plain.json b/src/main/resources/assets/etna/post_effect/etna_world_plain.json index af2cb7d..2a03d68 100644 --- a/src/main/resources/assets/etna/post_effect/etna_world_plain.json +++ b/src/main/resources/assets/etna/post_effect/etna_world_plain.json @@ -66,6 +66,10 @@ "sampler_name": "Depth", "target": "minecraft:main", "use_depth_buffer": true + }, + { + "sampler_name": "SsrHistory", + "target": "shafts" } ], "output": "water_ssr", diff --git a/src/main/resources/assets/etna/shaders/post/water_ssr.fsh b/src/main/resources/assets/etna/shaders/post/water_ssr.fsh index 9e5aac0..2a542fc 100644 --- a/src/main/resources/assets/etna/shaders/post/water_ssr.fsh +++ b/src/main/resources/assets/etna/shaders/post/water_ssr.fsh @@ -6,7 +6,17 @@ // 0.8.0 traced a different pixel of the block each frame along steps shifted by noise, for the // anti-aliasing to average, but where only some rays met a thin edge (leaves, a fence, a block's corner) // the anti-aliasing kept them as specks, and the reflections read as dots. The same rays every frame give -// a steady image that only moves with the camera and the waves. +// a steady image that only moves with the camera and the waves. The rays are traced where the pixel's +// centre would be without the anti-aliasing's jitter, and every read of the depth and the picture is moved +// by the jitter to where the picture holds that point, so a still scene gives the same rays every frame. +// +// Even so, where a ray only just meets or misses an edge it can go either way from one frame to the next +// (the jittered picture's edges move by up to half a pixel, the waves move on), and a whole 2x2 block, then +// spread over its neighbours by the world pass, changes at once: too smooth for the anti-aliasing, which +// keeps the old frames only where they fit the new one's neighbouring pixels, to tell from a real change, +// so the reflection flickered. With anti-aliasing on, each texel is now blended with the last frames' own +// result for the same point of the water (SSR_BLEND, moved to where the point was on last frame's screen): +// a ray that goes either way gives the average. // // Without improved transparency the water is drawn into Minecraft's picture after everything opaque, and // it writes the depth buffer: after the level the depth there is the water's surface, exactly where a @@ -50,6 +60,9 @@ uniform sampler2D InSampler; uniform sampler2D DepthSampler; +// This pass's result from the frames before (the world chain's "water_ssr0"; the plain chain, without +// anti-aliasing, never reads it). +uniform sampler2D SsrHistorySampler; layout(std140) uniform SamplerInfo { vec2 OutSize; @@ -88,6 +101,10 @@ const float THICKNESS = 1.5; const float THICKNESS_SHARE = 0.05; // Share of the screen, at each edge, over which the world's reflection fades into the sky's. const float EDGE_FADE = 0.07; +// How much of this frame's result each texel takes, against the last frames' for the same point of the +// water: about the last four frames count. While the camera moves, what the water mirrors shifts against +// the water itself (things nearer and further away by different amounts), and this frame counts more. +const float SSR_BLEND = 0.3; // Only faces turned within about 25 degrees of straight up reflect: the water's top (flowing water tilts // a little), not glass panes or the sides of a waterfall. const float LEVEL = 0.9; @@ -140,9 +157,54 @@ void nothing() { fragColor = vec4(0.0); } -// Water whose reflection stays the sky the water shader drew, dist blocks away. -void unchanged(float dist) { - fragColor = vec4(0.0, 0.0, 0.0, dist); +// The last frames' change for the water point at camera-relative `position`: rgb, and in a how much of it can +// be gone by (0 none: no history, the point off last frame's screen, or no water at its distance there). +vec4 lastChange(vec3 position) { + if (Frame.w < 0.5 || Motion.w < 0.5) { + return vec4(0.0); + } + // Last frame's camera was Motion.xyz behind this one's. + vec3 before = position + Motion.xyz; + vec4 h = PrevViewProj * vec4(before, 1.0); + if (h.w <= 0.0) { + return vec4(0.0); + } + vec2 uv = h.xy / h.w; + if (uv.x < 0.0 || uv.y < 0.0 || uv.x > 1.0 || uv.y > 1.0) { + return vec4(0.0); + } + // Texel c held pixel 2c: blend the four round the point, each only if it held water at the point's + // distance from last frame's camera. + vec2 c = (uv * DepthSize - 0.5) * 0.5; + ivec2 base = ivec2(floor(c)); + vec2 f = c - vec2(base); + ivec2 last = textureSize(SsrHistorySampler, 0) - 1; + float dist = length(before); + vec3 sum = vec3(0.0); + float weights = 0.0; + for (int i = 0; i < 4; i++) { + ivec2 o = ivec2(i & 1, i >> 1); + vec4 texel = texelFetch(SsrHistorySampler, clamp(base + o, ivec2(0), last), 0); + float w = (o.x == 1 ? f.x : 1.0 - f.x) * (o.y == 1 ? f.y : 1.0 - f.y); + if (texel.a > 0.0 && abs(texel.a - dist) < 0.3 + 0.03 * dist) { + sum += w * texel.rgb; + weights += w; + } + } + return weights > 0.01 ? vec4(sum / weights, weights) : vec4(0.0); +} + +// This frame's change for the water point at `position`, dist blocks away, blended with the last frames'. +void result(vec3 change, vec3 position, float dist) { + vec4 before = lastChange(position); + float moving = smoothstep(0.03, 0.3, length(Motion.xyz)); + float blend = mix(1.0, mix(SSR_BLEND, 1.0, moving), smoothstep(0.25, 1.0, before.a)); + fragColor = vec4(mix(before.rgb, change, blend), dist); +} + +// Water whose reflection stays the sky the water shader drew this frame, dist blocks away. +void unchanged(vec3 position, float dist) { + result(vec3(0.0), position, dist); } void main() { @@ -158,7 +220,10 @@ void main() { nothing(); return; } - vec2 uv = (vec2(pixel) + 0.5) / DepthSize; + // The picture is moved by +jitter pixels: the pixel shows the point that would sit at its centre less + // the jitter, and a point at uv lies at uv + jitter in the picture (0 without anti-aliasing). + vec2 jitter = Frame.yz / DepthSize; + vec2 uv = (vec2(pixel) + 0.5) / DepthSize - jitter; vec3 position = etnaPosition(uv, depth); // Water seen from above only. if (position.y > -RAY_LIFT) { @@ -212,7 +277,7 @@ void main() { float keep = (1.0 - hazed) * (1.0 - fogged); if (ray.y < 0.02 || mirror * keep < 0.004) { // A ray that skims the water, or a reflection too faint to change the picture. - unchanged(dist); + unchanged(position, dist); return; } @@ -240,7 +305,7 @@ void main() { exit = min(exit, -s0.z / span3.z); } if (exit <= 0.0) { - unchanged(dist); + unchanged(position, dist); return; } @@ -253,7 +318,7 @@ void main() { float t = exit * pow(float(i) / float(STEPS), STEP_CURVE); vec3 s = s0 + span3 * t; // Reversed depth: the ray lies behind what the depth buffer holds where its depth is smaller. - bool behind = s.z < depthAt(s.xy); + bool behind = s.z < depthAt(s.xy + jitter); if (behind && ahead) { // Halve the step down to the point where the ray passes behind that surface. float lo = before; @@ -261,14 +326,14 @@ void main() { for (int k = 0; k < HALVINGS; k++) { float mid = 0.5 * (lo + hi); vec3 m = s0 + span3 * mid; - if (m.z < depthAt(m.xy)) { + if (m.z < depthAt(m.xy + jitter)) { hi = mid; } else { lo = mid; } } vec3 h = s0 + span3 * hi; - vec3 there3 = vec3(h.xy, depthAt(h.xy)); + vec3 there3 = vec3(h.xy, depthAt(h.xy + jitter)); float surface = viewDepth(there3); if (surface > viewDepth(s0 + span3 * lo) - max(THICKNESS, THICKNESS_SHARE * surface) && etnaPosition(there3.xy, there3.z).y > position.y + RAY_ABOVE) { @@ -280,13 +345,13 @@ void main() { before = t; } if (found < 0.0) { - unchanged(dist); + unchanged(position, dist); return; } vec3 hit = s0 + span3 * found; vec2 edge = min(hit.xy, 1.0 - hit.xy); float trust = smoothstep(0.0, EDGE_FADE, min(edge.x, edge.y)); - vec3 world = texelFetch(InSampler, clamp(ivec2(hit.xy * InSize), ivec2(0), ivec2(InSize) - 1), 0).rgb; + vec3 world = texelFetch(InSampler, clamp(ivec2((hit.xy + jitter) * InSize), ivec2(0), ivec2(InSize) - 1), 0).rgb; // The sky's reflection the water drew, as far as this pixel's colour could hold it. // The terrain dims it with the open sky's light (etnaDaylight: low at night and in storms), which @@ -295,5 +360,5 @@ void main() { vec3 held = picture.rgb / max(sky, vec3(1e-4)); float open = clamp(min(openSky, min(held.r, min(held.g, held.b))), 0.0, 1.0); vec3 change = (world * (mirror * keep) - sky * open) * trust; - fragColor = vec4(change, dist); + result(change, position, dist); } diff --git a/tools/gen_post_effects.py b/tools/gen_post_effects.py index 450e101..e6cdee8 100644 --- a/tools/gen_post_effects.py +++ b/tools/gen_post_effects.py @@ -54,6 +54,12 @@ HISTORY_READ = "history0" HISTORY_WRITE = "history1" +# The water reflections' own history, kept the same way (with anti-aliasing only): the reflections pass reads +# the last frames' result from the first and writes this frame's into the second, which the world pass reads. +# PostChainMixin knows these names too. +SSR_READ = "water_ssr0" +SSR_WRITE = "water_ssr1" + # The light shafts' lit share and ray length, at half the screen's size (sized for 1920x1080 and scaled, as # the bloom levels are). SHAFTS_SIZE = (960, 540) @@ -84,9 +90,12 @@ def shafts_target(): return {"width": w, "height": h} -def water_ssr_target(): +def water_ssr_target(persistent=False): # The same half size as the shafts: the world pass maps its pixels onto both the same way. - return shafts_target() + t = shafts_target() + if persistent: + t["persistent"] = True + return t def shafts_pass(): @@ -96,15 +105,18 @@ def shafts_pass(): shadow=True) -def water_ssr_pass(): - # Reads the picture (its alpha marks the water) and the depth the water left; the EtnaShadow block holds - # this frame's sky, which the water mirrored. - return pas("etna:post/water_ssr", [inp("In", MAIN), inp("Depth", MAIN, depth=True)], "water_ssr", shadow=True) +def water_ssr_pass(history, output): + # Reads the picture (its alpha marks the water), the depth the water left and its own result from the + # frames before; the EtnaShadow block holds this frame's sky, which the water mirrored. Without + # anti-aliasing there is no history, and the input names the shafts only so the chain loads: the pass + # never reads it then. + return pas("etna:post/water_ssr", [inp("In", MAIN), inp("Depth", MAIN, depth=True), inp("SsrHistory", history)], + output, shadow=True) -def world_light_pass(output): +def world_light_pass(output, water_ssr): return pas("etna:post/world_light", [inp("In", MAIN), inp("Depth", MAIN, depth=True), inp("Shafts", "shafts"), - inp("WaterSsr", "water_ssr")], output, settings=True) + inp("WaterSsr", water_ssr)], output, settings=True) def copy_pass(source): @@ -115,13 +127,13 @@ def copy_pass(source): def world(): return { - "targets": {"shafts": shafts_target(), "water_ssr": water_ssr_target(), "air": {}, - HISTORY_READ: {"persistent": True}, HISTORY_WRITE: {"persistent": True}}, + "targets": {"shafts": shafts_target(), SSR_READ: water_ssr_target(True), SSR_WRITE: water_ssr_target(True), + "air": {}, HISTORY_READ: {"persistent": True}, HISTORY_WRITE: {"persistent": True}}, "passes": [ shafts_pass(), - water_ssr_pass(), + water_ssr_pass(SSR_READ, SSR_WRITE), # Before anti-aliasing, so the sky and the air in front of the land are smoothed with the rest. - world_light_pass("air"), + world_light_pass("air", SSR_WRITE), pas("etna:post/taa", [inp("In", "air"), inp("History", HISTORY_READ, bilinear=True), inp("Depth", MAIN, depth=True)], HISTORY_WRITE, settings=True), @@ -135,8 +147,8 @@ def world_plain(): "targets": {"shafts": shafts_target(), "water_ssr": water_ssr_target(), "lit": {}}, "passes": [ shafts_pass(), - water_ssr_pass(), - world_light_pass("lit"), + water_ssr_pass("shafts", "water_ssr"), + world_light_pass("lit", "water_ssr"), copy_pass("lit"), ], } From 34c3ba0a86d4aff6dd538f9ed944a501bdf5d71b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 02:38:48 +0000 Subject: [PATCH 09/24] Move the terrain's shared light functions into etna_light.glsl The sun and sky colours, the direct sun's strength, the sky light on a face, the soft terminator and the torch colour move unchanged from terrain.fsh into an include, so entities and items can be lit by the same functions. The terrain includes it where they were and draws exactly as before. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01F7MajtknSApr1T68EGCPtx --- .../assets/etna/shaders/core/terrain.fsh | 141 +---------------- .../etna/shaders/include/etna_light.glsl | 148 ++++++++++++++++++ 2 files changed, 149 insertions(+), 140 deletions(-) create mode 100644 src/main/resources/assets/etna/shaders/include/etna_light.glsl diff --git a/src/main/resources/assets/etna/shaders/core/terrain.fsh b/src/main/resources/assets/etna/shaders/core/terrain.fsh index ba3f0c6..3edbfc2 100644 --- a/src/main/resources/assets/etna/shaders/core/terrain.fsh +++ b/src/main/resources/assets/etna/shaders/core/terrain.fsh @@ -329,112 +329,7 @@ SurfaceDetail surfaceDetail(vec3 normal, float kind, vec3 posDx, vec3 posDy, vec return detail; } -// The brightness vanilla baked into this face's vertices (1 on top, darker on the sides and below). -float vanillaFaceShade(vec3 n, bool plant) { - vec4 shade = ETNA_FACE_SHADE; - if (plant) { - // Faces with "shade": false (crosses, crops) are shaded as if they pointed up. - return shade.y; - } - vec3 n2 = n * n; - return n2.x * shade.w + n2.z * shade.z + n2.y * (n.y > 0.0 ? shade.y : shade.x); -} - -// Colour of direct light: white-gold at noon, gold low in the sky, a deep orange at the horizon, a cool -// blue from the moon. With Etna's block it is the sky model's sunlight (EtnaSkyModel.sunlight: what the -// air leaves of the sun's light), brought to 0.6.0's brightness: a little under 1 at noon, three -// quarters with the sun low or the moon up, so the balance of sun and shade stays as it was tuned. -// Without the block or the model (draws with improved transparency, Etna switched off, the model failed) -// 0.6.0's own colours. -vec3 sunColor() { - vec3 c; - #ifdef ETNA_SHADOWS - if (ETNA_SUN_WORLD > 0.5 && ETNA_SKY_MODEL) { - c = etnaSunlight() * (ETNA_MOON ? 0.73 : mix(0.75, 0.97, smoothstep(0.04, 0.45, ETNA_LIGHT_DIR.y))); - } else - #endif - { - if (ETNA_MOON) { - return vec3(0.62, 0.74, 1.0); - } - vec3 noon = vec3(1.0, 0.97, 0.92); - vec3 low = vec3(1.0, 0.68, 0.40); - c = mix(low, noon, smoothstep(0.04, 0.45, ETNA_LIGHT_DIR.y)); - } - // The warmth setting nudges it: grey stone stays grey. - float w = ETNA_WARMTH; - return c * vec3(1.0 + 0.04 * w, 1.0 - 0.01 * w, 1.0 - 0.10 * w); -} - -// Colour of the light from the sky, luma about 1. With Etna's block, in a world with a sun, the sky -// model's skylight (EtnaSkyModel.skylight): a clear blue by day, warmer around sunset, navy at night, -// grey in rain. Elsewhere the hue of vanilla's fog colour (the horizon: blue by day, orange at sunset, -// the biome's own tint, the Nether's and the End's air) at low saturation. -vec3 skyColor() { - #ifdef ETNA_SHADOWS - if (ETNA_SUN_WORLD > 0.5 && ETNA_SKY_MODEL) { - // Under an aurora the night's skylight takes on a little of its green, at the same brightness - // (EtnaNight, EtnaSky.writeNight: 0 by day, in rain and with the Aurora setting off). - vec3 sky = etnaSkylight(); - return mix(sky, EtnaNight.rgb * dot(sky, LUMA), 0.22 * EtnaNight.w); - } - #endif - vec3 hue = FogColor.rgb / max(dot(FogColor.rgb, LUMA), 1e-3); - return clamp(mix(vec3(1.0), hue, 0.18), 0.0, 1.5); -} - -#ifdef ETNA_SHADOWS -// Ordered 4x4 dither; with temporal anti-aliasing on it shifts every frame, so a few steps per -// frame add up to a smooth result over a handful of frames instead of a fixed pattern. -float etnaDither() { - ivec2 cell = ivec2(gl_FragCoord.xy) & 3; - const float BAYER[16] = float[](0.0, 8.0, 2.0, 10.0, 12.0, 4.0, 14.0, 6.0, 3.0, 11.0, 1.0, 9.0, 15.0, 7.0, 13.0, 5.0); - float d = (BAYER[cell.y * 4 + cell.x] + 0.5) / 16.0; - return EtnaWorld.y > 0.5 ? fract(d + EtnaLook2.z * 0.618034) : d; -} -#endif - -// Strength of the direct sun, in vanilla's units (1 = a top face in vanilla daylight). Whatever the -// shade loses to the shadow darkness setting, the sun makes up on sunlit ground. -float directSun() { - float strength = ETNA_LIGHT_STRENGTH; - float shade = mix(0.95, mix(0.85, 0.45, ETNA_SHADOW_DARKNESS), strength); - return (1.08 - shade) / 0.9 * ETNA_SUNLIGHT * strength; -} - -// The sky light on a face, before its brightness. In a sunlit world, shade is lit by the blue sky -// alone and reads cooler than sunlit ground; on faces the sun reaches, its light outshines the sky's -// blue, so grey stone in the sun stays grey (and takes the sun's warmth). How blue the shade gets -// follows the shadow darkness setting: from its middle up all of the sky's colour, down to the sunlit -// faces' share at 0. With the sun up, walls see more of the sky's bright, warm side when they face the -// sun and of its darker blue side when they face away: each wall direction gets its own light, and a -// low sun warms the sky on the walls that face it. At night the sky's navy is the only light. -vec3 skyLightOn(vec3 normal, float faceNdl, float shadow) { - vec3 sky = skyColor(); - if (ETNA_MOON) { - return sky; - } - float strength = ETNA_LIGHT_STRENGTH; - vec2 flatSun = ETNA_LIGHT_DIR.xz; - float flatLength = dot(flatSun, flatSun); - if (flatLength > 1e-6) { - float toward = dot(normal.xz, flatSun) * inversesqrt(flatLength); - float low = 1.0 - smoothstep(0.1, 0.6, ETNA_LIGHT_DIR.y); - vec3 sun = sunColor(); - sky = mix(sky, sun / max(dot(sun, LUMA), 1e-3), 0.30 * max(toward, 0.0) * low * strength); - sky *= 1.0 + 0.14 * toward * strength; - } - float sunlit = shadow * smoothstep(0.0, 0.3, faceNdl) * strength; - float keep = mix(mix(0.4, 1.0, smoothstep(0.0, 0.5, ETNA_SHADOW_DARKNESS)), 0.4, sunlit); - return mix(vec3(dot(sky, LUMA)), sky, keep); -} - -// A face lit by the sun at the cosine ndl, the terminator softened a little: a face the sun only grazes -// gets a little more than Lambert's cosine (wrap lighting), so walls don't snap from lit to dark as the -// sun crosses their plane. Faces turned away keep none: the shadow map can't speak for them. -float softSun(float ndl) { - return ndl > 0.0 ? mix(ndl, (ndl + 0.15) / 1.15, smoothstep(0.0, 0.12, ndl)) : 0.0; -} +#include // Light reaching a sky-lit surface, in vanilla's units (1 = a top face in vanilla daylight): a soft // sky light from above plus direct sun where the shadow map lets it through, and the warm sunlight the @@ -476,40 +371,6 @@ vec3 skyLighting(vec3 normal, SurfaceDetail detail, bool thin, bool plant, float return sky * ambient * dome + sunlight * etnaSunReach() * (ndl * shadow) + sunlight * vec3(1.0, 0.86, 0.68) * (bounce * etnaSunReach()); } -// How far torch light moves from vanilla's colour towards a flame's. The Nether and the End are lit -// by little else: a strong orange cast there would bury their own colours. -float torchWarmth() { - return ETNA_TORCH_WARMTH * mix(0.35, 1.0, ETNA_SUN_WORLD); -} - -// Torches, lanterns, lava, glowstone. vanilla is vanilla's block light at this spot: its curve, the -// brightness setting, night vision and the dimension's ambient light are all in it already, so only -// its colour changes, to a flame that is yellow-white close to the source and deepens to orange as -// it fades, and its far reach dims a little so the light pools around the source. The light held in -// the player's hand is in vanilla here already (terrain.vsh) and takes the same flame where it -// outshines the blocks' own light, tinted by what is held: blue for soul fire, green for copper. -vec3 torchLight(vec3 vanilla) { - float warmth = torchWarmth(); - if (warmth <= 0.0) { - return vanilla; - } - float level = etnaLightLevels.x; - vec3 tint = vec3(1.0); - #ifdef ETNA_SHADOWS - if (EtnaHand.w > 0.0) { - float held = etnaHeldLevel(etnaViewPos) / 15.0; - // All the hand's colour two levels above the blocks' own light, none where the blocks' is brighter. - tint = mix(vec3(1.0), EtnaHand.rgb, clamp((held - level) * 7.5, 0.0, 1.0)); - level = max(level, held); - } - #endif - vec3 flame = mix(vec3(1.0, 0.60, 0.30), vec3(1.0, 0.86, 0.66), smoothstep(0.45, 0.95, level)) * tint; - // Same brightness as vanilla's light (luma 1 times its luma): only the hue moves. - flame /= dot(flame, LUMA); - float falloff = mix(0.72, 1.0, smoothstep(0.3, 0.93, level)); - return mix(vanilla, flame * (dot(vanilla, LUMA) * falloff), warmth); -} - // Rain soaks ground lying straight under the open sky, which stays wet for a minute or two after the // rain stops (ETNA_WETNESS, worked out by EtnaTouch.java). Only full sky light counts: a block of water // or leaves overhead already dims it a level, so sea beds and the ground under trees stay dry. Very diff --git a/src/main/resources/assets/etna/shaders/include/etna_light.glsl b/src/main/resources/assets/etna/shaders/include/etna_light.glsl new file mode 100644 index 0000000..87c24bc --- /dev/null +++ b/src/main/resources/assets/etna/shaders/include/etna_light.glsl @@ -0,0 +1,148 @@ +// Etna's light on a surface, shared by the terrain (core/terrain.fsh) and by entities, block entities and +// items (core/entity.fsh, core/item.fsh through entity_light.glsl): the colours of the sun and the sky, how +// strong the direct sun is, the sky's light on a face by the way it faces, the soft terminator, and the warm +// colour of torch light. The terrain's skyLighting puts them together for blocks, entity_light.glsl for +// models. Moved out of terrain.fsh unchanged in 0.9.0, so the terrain is lit exactly as before. +// The includer declares, before including this: LUMA, terrain_ubo.glsl's block and names, sky.glsl, +// terrain_touch.glsl, etnaViewPos (the camera-relative position) and etnaLightLevels (x: block light, 0 .. 1). + +// The brightness vanilla baked into this face's vertices (1 on top, darker on the sides and below). +float vanillaFaceShade(vec3 n, bool plant) { + vec4 shade = ETNA_FACE_SHADE; + if (plant) { + // Faces with "shade": false (crosses, crops) are shaded as if they pointed up. + return shade.y; + } + vec3 n2 = n * n; + return n2.x * shade.w + n2.z * shade.z + n2.y * (n.y > 0.0 ? shade.y : shade.x); +} + +// Colour of direct light: white-gold at noon, gold low in the sky, a deep orange at the horizon, a cool +// blue from the moon. With Etna's block it is the sky model's sunlight (EtnaSkyModel.sunlight: what the +// air leaves of the sun's light), brought to 0.6.0's brightness: a little under 1 at noon, three +// quarters with the sun low or the moon up, so the balance of sun and shade stays as it was tuned. +// Without the block or the model (draws with improved transparency, Etna switched off, the model failed) +// 0.6.0's own colours. +vec3 sunColor() { + vec3 c; + #ifdef ETNA_SHADOWS + if (ETNA_SUN_WORLD > 0.5 && ETNA_SKY_MODEL) { + c = etnaSunlight() * (ETNA_MOON ? 0.73 : mix(0.75, 0.97, smoothstep(0.04, 0.45, ETNA_LIGHT_DIR.y))); + } else + #endif + { + if (ETNA_MOON) { + return vec3(0.62, 0.74, 1.0); + } + vec3 noon = vec3(1.0, 0.97, 0.92); + vec3 low = vec3(1.0, 0.68, 0.40); + c = mix(low, noon, smoothstep(0.04, 0.45, ETNA_LIGHT_DIR.y)); + } + // The warmth setting nudges it: grey stone stays grey. + float w = ETNA_WARMTH; + return c * vec3(1.0 + 0.04 * w, 1.0 - 0.01 * w, 1.0 - 0.10 * w); +} + +// Colour of the light from the sky, luma about 1. With Etna's block, in a world with a sun, the sky +// model's skylight (EtnaSkyModel.skylight): a clear blue by day, warmer around sunset, navy at night, +// grey in rain. Elsewhere the hue of vanilla's fog colour (the horizon: blue by day, orange at sunset, +// the biome's own tint, the Nether's and the End's air) at low saturation. +vec3 skyColor() { + #ifdef ETNA_SHADOWS + if (ETNA_SUN_WORLD > 0.5 && ETNA_SKY_MODEL) { + // Under an aurora the night's skylight takes on a little of its green, at the same brightness + // (EtnaNight, EtnaSky.writeNight: 0 by day, in rain and with the Aurora setting off). + vec3 sky = etnaSkylight(); + return mix(sky, EtnaNight.rgb * dot(sky, LUMA), 0.22 * EtnaNight.w); + } + #endif + vec3 hue = FogColor.rgb / max(dot(FogColor.rgb, LUMA), 1e-3); + return clamp(mix(vec3(1.0), hue, 0.18), 0.0, 1.5); +} + +#ifdef ETNA_SHADOWS +// Ordered 4x4 dither; with temporal anti-aliasing on it shifts every frame, so a few steps per +// frame add up to a smooth result over a handful of frames instead of a fixed pattern. +float etnaDither() { + ivec2 cell = ivec2(gl_FragCoord.xy) & 3; + const float BAYER[16] = float[](0.0, 8.0, 2.0, 10.0, 12.0, 4.0, 14.0, 6.0, 3.0, 11.0, 1.0, 9.0, 15.0, 7.0, 13.0, 5.0); + float d = (BAYER[cell.y * 4 + cell.x] + 0.5) / 16.0; + return EtnaWorld.y > 0.5 ? fract(d + EtnaLook2.z * 0.618034) : d; +} +#endif + +// Strength of the direct sun, in vanilla's units (1 = a top face in vanilla daylight). Whatever the +// shade loses to the shadow darkness setting, the sun makes up on sunlit ground. +float directSun() { + float strength = ETNA_LIGHT_STRENGTH; + float shade = mix(0.95, mix(0.85, 0.45, ETNA_SHADOW_DARKNESS), strength); + return (1.08 - shade) / 0.9 * ETNA_SUNLIGHT * strength; +} + +// The sky light on a face, before its brightness. In a sunlit world, shade is lit by the blue sky +// alone and reads cooler than sunlit ground; on faces the sun reaches, its light outshines the sky's +// blue, so grey stone in the sun stays grey (and takes the sun's warmth). How blue the shade gets +// follows the shadow darkness setting: from its middle up all of the sky's colour, down to the sunlit +// faces' share at 0. With the sun up, walls see more of the sky's bright, warm side when they face the +// sun and of its darker blue side when they face away: each wall direction gets its own light, and a +// low sun warms the sky on the walls that face it. At night the sky's navy is the only light. +vec3 skyLightOn(vec3 normal, float faceNdl, float shadow) { + vec3 sky = skyColor(); + if (ETNA_MOON) { + return sky; + } + float strength = ETNA_LIGHT_STRENGTH; + vec2 flatSun = ETNA_LIGHT_DIR.xz; + float flatLength = dot(flatSun, flatSun); + if (flatLength > 1e-6) { + float toward = dot(normal.xz, flatSun) * inversesqrt(flatLength); + float low = 1.0 - smoothstep(0.1, 0.6, ETNA_LIGHT_DIR.y); + vec3 sun = sunColor(); + sky = mix(sky, sun / max(dot(sun, LUMA), 1e-3), 0.30 * max(toward, 0.0) * low * strength); + sky *= 1.0 + 0.14 * toward * strength; + } + float sunlit = shadow * smoothstep(0.0, 0.3, faceNdl) * strength; + float keep = mix(mix(0.4, 1.0, smoothstep(0.0, 0.5, ETNA_SHADOW_DARKNESS)), 0.4, sunlit); + return mix(vec3(dot(sky, LUMA)), sky, keep); +} + +// A face lit by the sun at the cosine ndl, the terminator softened a little: a face the sun only grazes +// gets a little more than Lambert's cosine (wrap lighting), so walls don't snap from lit to dark as the +// sun crosses their plane. Faces turned away keep none: the shadow map can't speak for them. +float softSun(float ndl) { + return ndl > 0.0 ? mix(ndl, (ndl + 0.15) / 1.15, smoothstep(0.0, 0.12, ndl)) : 0.0; +} + +// How far torch light moves from vanilla's colour towards a flame's. The Nether and the End are lit +// by little else: a strong orange cast there would bury their own colours. +float torchWarmth() { + return ETNA_TORCH_WARMTH * mix(0.35, 1.0, ETNA_SUN_WORLD); +} + +// Torches, lanterns, lava, glowstone. vanilla is vanilla's block light at this spot: its curve, the +// brightness setting, night vision and the dimension's ambient light are all in it already, so only +// its colour changes, to a flame that is yellow-white close to the source and deepens to orange as +// it fades, and its far reach dims a little so the light pools around the source. The light held in +// the player's hand is in vanilla here already (terrain.vsh) and takes the same flame where it +// outshines the blocks' own light, tinted by what is held: blue for soul fire, green for copper. +vec3 torchLight(vec3 vanilla) { + float warmth = torchWarmth(); + if (warmth <= 0.0) { + return vanilla; + } + float level = etnaLightLevels.x; + vec3 tint = vec3(1.0); + #ifdef ETNA_SHADOWS + if (EtnaHand.w > 0.0) { + float held = etnaHeldLevel(etnaViewPos) / 15.0; + // All the hand's colour two levels above the blocks' own light, none where the blocks' is brighter. + tint = mix(vec3(1.0), EtnaHand.rgb, clamp((held - level) * 7.5, 0.0, 1.0)); + level = max(level, held); + } + #endif + vec3 flame = mix(vec3(1.0, 0.60, 0.30), vec3(1.0, 0.86, 0.66), smoothstep(0.45, 0.95, level)) * tint; + // Same brightness as vanilla's light (luma 1 times its luma): only the hue moves. + flame /= dot(flame, LUMA); + float falloff = mix(0.72, 1.0, smoothstep(0.3, 0.93, level)); + return mix(vanilla, flame * (dot(vanilla, LUMA) * falloff), warmth); +} From 3118e9b52dd560a0279d4326a53b614aeecb834e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 02:45:07 +0000 Subject: [PATCH 10/24] Entity and item shaders lit like the terrain Etna's own entity and item shaders, made from vanilla's: everything is vanilla's but the light, which is the terrain's (sun, sky, shadow map, clouds, torch colour, cave light, haze), shared through etna_light.glsl. The first-person hand is lit as if it stood at the camera. The validator builds them with the defines of the pipelines Etna will copy. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01F7MajtknSApr1T68EGCPtx --- .../assets/etna/shaders/core/entity.fsh | 119 ++++++++++++++ .../assets/etna/shaders/core/entity.vsh | 78 +++++++++ .../assets/etna/shaders/core/item.fsh | 92 +++++++++++ .../assets/etna/shaders/core/item.vsh | 72 +++++++++ .../etna/shaders/include/entity_light.glsl | 148 ++++++++++++++++++ .../shaders/include/entity_light_vertex.glsl | 41 +++++ tools/validate_shaders.py | 45 +++++- 7 files changed, 591 insertions(+), 4 deletions(-) create mode 100644 src/main/resources/assets/etna/shaders/core/entity.fsh create mode 100644 src/main/resources/assets/etna/shaders/core/entity.vsh create mode 100644 src/main/resources/assets/etna/shaders/core/item.fsh create mode 100644 src/main/resources/assets/etna/shaders/core/item.vsh create mode 100644 src/main/resources/assets/etna/shaders/include/entity_light.glsl create mode 100644 src/main/resources/assets/etna/shaders/include/entity_light_vertex.glsl diff --git a/src/main/resources/assets/etna/shaders/core/entity.fsh b/src/main/resources/assets/etna/shaders/core/entity.fsh new file mode 100644 index 0000000..e8f0437 --- /dev/null +++ b/src/main/resources/assets/etna/shaders/core/entity.fsh @@ -0,0 +1,119 @@ +#version 330 +#extension GL_ARB_separate_shader_objects : require +// Based on Minecraft's vanilla shader, (c) Mojang AB, not covered by Etna's MIT licence (CREDITS.md). +// The shadow filter's textureGather (include/terrain_shadow.glsl), enabled before any code as the GLSL spec +// wants; a compiler without it only warns here, and the include then reads one texel at a time. +#extension GL_ARB_texture_gather : enable + +// Etna's entity fragment shader, made from vanilla 26.4's. Only Etna's copies of vanilla's entity pipelines use +// it (EtnaEntities.java), and only while Etna is on: with Etna off the game draws with its own shader. +// Everything is vanilla's (the texture, the alpha test, the hurt flash, the dissolve, ColorModulator, the +// enchantment glint, the fog, the improved transparency stages) but the light: where vanilla multiplies by its +// lightmap colour, Etna multiplies by the light the terrain would have in the same place (entity_light.glsl), and +// adds the terrain's distance haze before the fog. + +#include +#include +#include +#include +#include +#include + +uniform sampler2D Sampler0; + +#ifdef DISSOLVE +uniform sampler2D DissolveMaskSampler; +#endif + +#ifdef GLINT +uniform sampler2D GlintSampler; +#endif + +layout(location = 0) in float sphericalVertexDistance; +layout(location = 1) in float cylindricalVertexDistance; +layout(location = 2) in vec4 vertexColor; + +#ifndef NO_OVERLAY +layout(location = 5) in vec4 overlayColor; +#endif + +layout(location = 6) in vec2 texCoord0; +#ifdef GLINT +layout(location = 7) in vec2 texCoordGlint; +#endif + +#ifndef OIT_ALPHA_ONLY +layout(location = 0) out vec4 fragColor; +#endif + +#if !defined(EMISSIVE) && !defined(OIT_ALPHA_ONLY) +#include +#endif + +vec4 calculateFinalColor(vec4 color) { + #ifndef NO_OVERLAY + color.rgb = mix(overlayColor.rgb, color.rgb, overlayColor.a); + #endif + + #if !defined(EMISSIVE) && !defined(OIT_ALPHA_ONLY) + color.rgb *= etnaModelLight(etnaModelNormalHere()); + #endif + + #ifdef GLINT + vec4 glintColor = GlintAlpha * texture(GlintSampler, texCoordGlint); + // Matches BlendFuntion.GLINT + color.rgb += glintColor.rgb * glintColor.rgb; + #endif + + #if !defined(EMISSIVE) && !defined(OIT_ALPHA_ONLY) + color.rgb = etnaModelHaze(color.rgb); + #endif + + color = apply_fog(color, sphericalVertexDistance, cylindricalVertexDistance, FogEnvironmentalStart, FogEnvironmentalEnd, FogRenderDistanceStart, FogRenderDistanceEnd, FogColor); + + #ifdef OIT_ACCUMULATE + color = sampleColorForAccumulation(color); + #endif + + return color; +} + +void main() { + #if !defined(EMISSIVE) && !defined(OIT_ALPHA_ONLY) + etnaModelBegin(); + #endif + + vec4 color = texture(Sampler0, texCoord0); + + #ifdef OIT_ADDITIVE + color.a = min(0.99, color.a); + #endif + + #ifdef ALPHA_CUTOUT + if (color.a < ALPHA_CUTOUT) { + discard; + } + #endif + + vec4 faceVertexColor = vertexColor; + + #ifdef DISSOLVE + if (faceVertexColor.a < texture(DissolveMaskSampler, texCoord0).a) { + discard; + } + // The dissolve effect entirely replaces translucency + faceVertexColor.a = 1.0; + #endif + + color *= faceVertexColor * ColorModulator; + + #ifdef GLINT + color.a = max(color.a, GlintAlpha); + #endif + + #ifdef OIT_ALPHA_ONLY + executeAlphaOnlyPhase(gl_FragCoord.z, color.a); + #else + fragColor = calculateFinalColor(color); + #endif +} diff --git a/src/main/resources/assets/etna/shaders/core/entity.vsh b/src/main/resources/assets/etna/shaders/core/entity.vsh new file mode 100644 index 0000000..729e90d --- /dev/null +++ b/src/main/resources/assets/etna/shaders/core/entity.vsh @@ -0,0 +1,78 @@ +#version 330 +#extension GL_ARB_separate_shader_objects : require + +// Based on Minecraft's vanilla shader, (c) Mojang AB, not covered by Etna's MIT licence (CREDITS.md). +// Etna's entity vertex shader, made from vanilla 26.4's: mobs, block entities (chests, banners, shulker boxes, +// skulls...), armour, item frames and the arm. Only Etna's copies of vanilla's entity pipelines use it +// (EtnaEntities.java), and only while Etna is on: with Etna off the game draws with its own shader. +// Everything is vanilla's but the light: vanilla's two fixed lights and its lightmap colour make way for the +// light split of entity_light_vertex.glsl, which entity.fsh lights with the sun, the sky and the shadow map as +// the terrain is. The vertex colour goes on unlit, so the per-face colours of PER_FACE_LIGHTING are one colour +// here and entity.fsh turns the normal round on the back of the face instead. + +#include +#include +#include +#include +#include +#include +#include + +layout(location = 0) in vec3 Position; +layout(location = 1) in vec4 Color; +layout(location = 2) in vec2 UV0; +layout(location = 3) in ivec2 UV1; +layout(location = 4) in ivec2 UV2; +layout(location = 5) in vec3 Normal; + +#if !defined(NO_OVERLAY) && !defined(OIT_ALPHA_ONLY) +uniform sampler2D Sampler1; +#endif + +#if !defined(EMISSIVE) && !defined(OIT_ALPHA_ONLY) +uniform sampler2D Sampler2; +#endif + +layout(location = 0) out float sphericalVertexDistance; +layout(location = 1) out float cylindricalVertexDistance; +layout(location = 2) out vec4 vertexColor; + +#ifndef NO_OVERLAY +layout(location = 5) out vec4 overlayColor; +#endif + +layout(location = 6) out vec2 texCoord0; +#ifdef GLINT +layout(location = 7) out vec2 texCoordGlint; +#endif + +#if !defined(EMISSIVE) && !defined(OIT_ALPHA_ONLY) +#include +#endif + +void main() { + gl_Position = ProjMat * ModelViewMat * vec4(Position, 1.0); + + sphericalVertexDistance = fog_spherical_distance(Position); + cylindricalVertexDistance = fog_cylindrical_distance(Position); + + vertexColor = Color; + +#if !defined(EMISSIVE) && !defined(OIT_ALPHA_ONLY) + etnaModelLightVertex(Position, Normal, UV2); +#endif + +#if !defined(NO_OVERLAY) && !defined(OIT_ALPHA_ONLY) + overlayColor = texelFetch(Sampler1, UV1, 0); +#endif + + texCoord0 = UV0; + +#ifdef APPLY_TEXTURE_MATRIX + texCoord0 = (TextureMat * vec4(UV0, 0.0, 1.0)).xy; +#endif + +#ifdef GLINT + texCoordGlint = (TextureMat * vec4(UV0, 0.0, 1.0)).xy; +#endif +} diff --git a/src/main/resources/assets/etna/shaders/core/item.fsh b/src/main/resources/assets/etna/shaders/core/item.fsh new file mode 100644 index 0000000..78e2f86 --- /dev/null +++ b/src/main/resources/assets/etna/shaders/core/item.fsh @@ -0,0 +1,92 @@ +#version 330 +#extension GL_ARB_separate_shader_objects : require +// Based on Minecraft's vanilla shader, (c) Mojang AB, not covered by Etna's MIT licence (CREDITS.md). +// The shadow filter's textureGather (include/terrain_shadow.glsl), enabled before any code as the GLSL spec +// wants; a compiler without it only warns here, and the include then reads one texel at a time. +#extension GL_ARB_texture_gather : enable + +// Etna's item fragment shader, made from vanilla 26.4's. Only Etna's copies of vanilla's item pipelines use it +// (EtnaEntities.java), and only while Etna is on and outside the menus: with Etna off the game draws with its +// own shader. Everything is vanilla's (the texture, the alpha test, the hurt flash, ColorModulator, the +// enchantment glint, the fog, the improved transparency stages) but the light: where vanilla multiplies by its +// lightmap colour, Etna multiplies by the light the terrain would have in the same place (entity_light.glsl), and +// adds the terrain's distance haze before the fog. + +#include +#include +#include +#include +#include +#include + +uniform sampler2D Sampler0; + +#ifdef GLINT +uniform sampler2D GlintSampler; +#endif + +#ifndef OIT_ALPHA_ONLY +layout(location = 0) in float sphericalVertexDistance; +layout(location = 1) in float cylindricalVertexDistance; +#endif +layout(location = 2) in vec4 vertexColor; +#ifndef OIT_ALPHA_ONLY +layout(location = 4) in vec4 overlayColor; +#endif +layout(location = 5) in vec2 texCoord0; +#ifdef GLINT +layout(location = 6) in vec2 texCoordGlint; +#endif + +#ifndef OIT_ALPHA_ONLY +layout(location = 0) out vec4 fragColor; +#endif + +#ifndef OIT_ALPHA_ONLY +#include + +vec4 calculateFinalColor(vec4 color) { + color.rgb = mix(overlayColor.rgb, color.rgb, overlayColor.a); + color.rgb *= etnaModelLight(etnaModelNormalHere()); + + #ifdef GLINT + vec4 glintColor = GlintAlpha * texture(GlintSampler, texCoordGlint); + // Matches BlendFuntion.GLINT + color.rgb += glintColor.rgb * glintColor.rgb; + #endif + + color.rgb = etnaModelHaze(color.rgb); + color = apply_fog(color, sphericalVertexDistance, cylindricalVertexDistance, FogEnvironmentalStart, FogEnvironmentalEnd, FogRenderDistanceStart, FogRenderDistanceEnd, FogColor); + + #ifdef OIT_ACCUMULATE + color = sampleColorForAccumulation(color); + #endif + + return color; +} +#endif + +void main() { + #ifndef OIT_ALPHA_ONLY + etnaModelBegin(); + #endif + + vec4 color = texture(Sampler0, texCoord0); + #ifdef ALPHA_CUTOUT + if (color.a < ALPHA_CUTOUT) { + discard; + } + #endif + + color *= vertexColor * ColorModulator; + + #ifdef GLINT + color.a = max(color.a, GlintAlpha); + #endif + + #ifdef OIT_ALPHA_ONLY + executeAlphaOnlyPhase(gl_FragCoord.z, color.a); + #else + fragColor = calculateFinalColor(color); + #endif +} diff --git a/src/main/resources/assets/etna/shaders/core/item.vsh b/src/main/resources/assets/etna/shaders/core/item.vsh new file mode 100644 index 0000000..bf1b7db --- /dev/null +++ b/src/main/resources/assets/etna/shaders/core/item.vsh @@ -0,0 +1,72 @@ +#version 330 +#extension GL_ARB_separate_shader_objects : require + +// Based on Minecraft's vanilla shader, (c) Mojang AB, not covered by Etna's MIT licence (CREDITS.md). +// Etna's item vertex shader, made from vanilla 26.4's: dropped items, items in frames and held items, in the +// world and in the hand. Only Etna's copies of vanilla's item pipelines use it (EtnaEntities.java), and only +// while Etna is on and outside the menus: with Etna off, and for every item in the inventory and the other +// menus, the game draws with its own shader. Everything is vanilla's but the light: vanilla's two fixed lights +// and its lightmap colour make way for the light split of entity_light_vertex.glsl, which item.fsh lights with +// the sun, the sky and the shadow map as the terrain is. + +#include +#include +#include +#include +#include +#include +#include + +layout(location = 0) in vec3 Position; +layout(location = 1) in vec4 Color; +layout(location = 2) in vec2 UV0; +layout(location = 3) in ivec2 UV1; +layout(location = 4) in ivec2 UV2; +#ifdef GLINT_SPECIAL +layout(location = 5) in vec2 UV3; +#endif +layout(location = 6) in vec3 Normal; + +#ifndef OIT_ALPHA_ONLY +uniform sampler2D Sampler1; +uniform sampler2D Sampler2; + +layout(location = 0) out float sphericalVertexDistance; +layout(location = 1) out float cylindricalVertexDistance; +#endif +layout(location = 2) out vec4 vertexColor; +#ifndef OIT_ALPHA_ONLY +layout(location = 4) out vec4 overlayColor; +#endif + +layout(location = 5) out vec2 texCoord0; +#ifdef GLINT +layout(location = 6) out vec2 texCoordGlint; +#endif + +#ifndef OIT_ALPHA_ONLY +#include +#endif + +void main() { + gl_Position = ProjMat * ModelViewMat * vec4(Position, 1.0); + + #ifndef OIT_ALPHA_ONLY + sphericalVertexDistance = fog_spherical_distance(Position); + cylindricalVertexDistance = fog_cylindrical_distance(Position); + #endif + vertexColor = Color; + #ifndef OIT_ALPHA_ONLY + etnaModelLightVertex(Position, Normal, UV2); + overlayColor = texelFetch(Sampler1, UV1, 0); + #endif + + texCoord0 = UV0; + #ifdef GLINT + #ifdef GLINT_SPECIAL + texCoordGlint = (TextureMat * vec4(UV3, 0.0, 1.0)).xy; + #else + texCoordGlint = (TextureMat * vec4(UV0, 0.0, 1.0)).xy; + #endif + #endif +} diff --git a/src/main/resources/assets/etna/shaders/include/entity_light.glsl b/src/main/resources/assets/etna/shaders/include/entity_light.glsl new file mode 100644 index 0000000..704ea36 --- /dev/null +++ b/src/main/resources/assets/etna/shaders/include/entity_light.glsl @@ -0,0 +1,148 @@ +// Etna's light on entities, block entities, armour and items, fragment half: core/entity.fsh and core/item.fsh +// include it after minecraft:fog.glsl, minecraft:globals.glsl, terrain_ubo.glsl and terrain_touch.glsl. +// entity_light_vertex.glsl writes what it reads. Only Etna's copies of vanilla's entity and item pipelines draw +// with these shaders (EtnaEntities.java), always with Etna's block, its shadow maps and the cloud texture bound +// (ETNA_SHADOWS), so all of them are there whenever this runs. +// +// Vanilla lights a model with two fixed lights and its lightmap. Here a model is lit the way the terrain lights a +// block (terrain.fsh, shadeTerrain and skyLighting), with the same functions (etna_light.glsl): the sky's share of +// vanilla's light is lit again with the sky, brightest from above and tinted by the way the face turns to the sun, +// and with the sun where the shadow map and the clouds let it through, and the share that torches add takes their +// warm colour. A model's surface is a plain one: no bumps from its texture, no sheen, no glowing texels. So a white +// cube standing on white ground comes out as bright as the ground's own top and sides in the same light. +// +// ETNA_HAND is the first-person hand and what it holds: drawn after the level, close to the eye, with the hand's +// own projection. It is lit as if it stood where the camera is: it takes the shade of the tree or the roof over the +// player, as the land around does, rather than the shadow map's tiny texels at the hand's own place, which would +// flicker over it as the player walks. + +#ifndef OIT_ALPHA_ONLY +layout(location = 8) in vec3 etnaModelPos; +layout(location = 9) in vec3 etnaLightmap; +layout(location = 10) in vec3 etnaTorchLight; +layout(location = 11) in vec3 etnaLightLevels; +layout(location = 12) in vec3 etnaModelNormal; + +uniform sampler2D EtnaShadowSampler; +// The other of the two maps, while the shadows fade from one to the other (terrain_shadow.glsl). +uniform sampler2D EtnaShadowSampler2; + +const vec3 LUMA = vec3(0.2126, 0.7152, 0.0722); + +// The names the terrain's includes read, as terrain.fsh has them: set by etnaModelBegin, before any lighting. +// etnaViewPos is the camera-relative position the shadow map, the clouds and the haze are read at, etnaWorldPos +// the same in the world (only its height is read, by the Nether's lava light), etnaSkyLight the open-sky gate +// terrain.vsh works out per vertex (0 in caves .. 1 under open sky). +vec3 etnaViewPos = vec3(0.0); +vec3 etnaWorldPos = vec3(0.0); +float etnaSkyLight = 0.0; + +#include +#include +#include +#include +#include + +// How open this place is to the light from above, for how deep the shade goes (terrain_dark.glsl's +// etnaShadeWeight). On land the terrain takes the open-sky gate; its water hooks, which change that under water, +// are the terrain's own, and a model under water keeps the land's. +float etnaShadeOpen() { + return etnaSkyLight; +} + +#include + +// Sets the names above for this fragment. +void etnaModelBegin() { + #ifdef ETNA_HAND + etnaViewPos = vec3(0.0); + #else + etnaViewPos = etnaModelPos; + #endif + // Globals: the camera stands at CameraBlockPos - CameraOffset. + etnaWorldPos = etnaViewPos + vec3(CameraBlockPos) - CameraOffset; + etnaSkyLight = smoothstep(7.0 / 15.0, 13.0 / 15.0, etnaLightLevels.y); +} + +#ifdef ETNA_HAND +// The sun at the camera, for the hand: the shadow map read at the camera and at eight points on a ring 0.4 blocks +// round it, each blended over the four texels round it (shadowSoftAt). Walking out from under a tree, the hand +// brightens over about a block in small steps rather than at once, and it never changes from frame to frame +// while the player stands still. No dither: the hand is drawn after the anti-aliasing, which can't smooth it. +float etnaHandShadow() { + float lit = shadowSoftAt(vec3(0.0)); + for (int i = 0; i < 8; i++) { + float angle = float(i) * (6.2831853 / 8.0); + lit += shadowSoftAt(vec3(cos(angle), 0.0, sin(angle)) * 0.4); + } + return lit / 9.0; +} +#endif + +// A model's normal for lighting: vanilla's vertex normal, unit length, turned round on the back of faces drawn +// from both sides (PER_FACE_LIGHTING, where vanilla lights the back with the normal turned round too). +vec3 etnaModelNormalHere() { + vec3 n = etnaModelNormal; + float len2 = dot(n, n); + // A model without normals: light it as a top face rather than let normalize() make NaN of it. + n = len2 > 1e-8 ? n * inversesqrt(len2) : vec3(0.0, 1.0, 0.0); + #ifdef PER_FACE_LIGHTING + if (!gl_FrontFacing) { + n = -n; + } + #endif + return n; +} + +// The light on a model's surface, in vanilla's units (1 = a top face in vanilla daylight): what Etna multiplies +// the model's colour by where vanilla multiplies it by its lightmap. shadeTerrain and skyLighting in terrain.fsh +// for a flat surface that is neither leaves nor a plant, in the same order. The model's colour has no face +// shading or ambient occlusion baked in to undo, so none is divided out. +vec3 etnaModelLight(vec3 normal) { + float facing = dot(normal, ETNA_LIGHT_DIR); + float shadow = 1.0; + // Faces turned away from the sun get no sun whatever the map says, so they skip the lookups. + if (ETNA_LIGHT_STRENGTH * etnaSkyLight > 0.01 && facing > 0.0) { + if (EtnaLook2.w > 0.5) { + #ifdef ETNA_HAND + shadow = etnaHandShadow(); + #else + shadow = shadowVisibility(normal, false); + #endif + } + // Clouds drifting over the sun, with or without the shadow map. + shadow *= etnaCloudShadow(); + } + + // Vanilla's light here, in two parts, as on the terrain: the sky's, relit below, and what torches add, + // which keeps vanilla's per-face shading and takes the torch colour. Next to a torch the sky's part greys. + vec3 skyPart = max(etnaLightmap - etnaTorchLight, 0.0); + vec3 torch = torchLight(etnaTorchLight) * etnaTorchBoost(); + float torchLuma = dot(etnaTorchLight, LUMA); + float skyLuma = dot(skyPart, LUMA); + skyPart = mix(skyPart, vec3(skyLuma), torchWarmth() * torchLuma / max(torchLuma + skyLuma, 1e-4)); + + // skyLighting: the sky brightest from above (tops all of it, sides most, undersides about half; east and + // west a few percent less than north and south), the sun with the soft terminator where the shadow map lets + // it through, and the sunlit ground's warm light thrown back up onto sides and undersides. The sky's up and + // down and its warmer side towards the sun keep a mob's faces apart in the shade and under an overcast sky, + // as vanilla's two lights do. + float ambient = mix(0.95, mix(0.85, 0.45, ETNA_SHADOW_DARKNESS), etnaShadeWeight()); + float up = normal.y; + float dome = (0.78 + 0.22 * up) * (1.0 - 0.06 * normal.x * normal.x); + float faceNdl = max(facing, 0.0); + float ndl = softSun(faceNdl); + vec3 sky = skyLightOn(normal, faceNdl, shadow); + vec3 sunlight = sunColor() * directSun(); + float bounce = ETNA_MOON ? 0.0 : 0.20 * max(ETNA_LIGHT_DIR.y, 0.0) * (0.5 - 0.5 * up); + vec3 skyLight = sky * ambient * dome + sunlight * etnaSkyLight * (ndl * shadow) + + sunlight * vec3(1.0, 0.86, 0.68) * (bounce * etnaSkyLight); + return skyLight * skyPart + vanillaFaceShade(normal, false) * torch + etnaLavaLight(normal); +} + +// The distance haze, as on the terrain: only while the camera is out of the water (EtnaWater.x 0), where the +// terrain draws it too. +vec3 etnaModelHaze(vec3 color) { + return EtnaWater.x < 0.5 ? aerialHaze(color) : color; +} +#endif diff --git a/src/main/resources/assets/etna/shaders/include/entity_light_vertex.glsl b/src/main/resources/assets/etna/shaders/include/entity_light_vertex.glsl new file mode 100644 index 0000000..0a6149f --- /dev/null +++ b/src/main/resources/assets/etna/shaders/include/entity_light_vertex.glsl @@ -0,0 +1,41 @@ +// Etna's light on entities, block entities, armour and items, vertex half: core/entity.vsh and core/item.vsh +// include it after terrain_ubo.glsl, terrain_touch.glsl, terrain_dark_vertex.glsl and minecraft:sample_lightmap.glsl, +// and after declaring Sampler2 (vanilla's lightmap). entity_light.glsl is the fragment half and reads what this +// writes. Only Etna's copies of vanilla's entity and item pipelines draw with these shaders (EtnaEntities.java), +// always with Etna's block bound (ETNA_SHADOWS), so the block is there whenever this runs. +// +// Vanilla multiplies its lightmap into the colour at every vertex. Etna splits it the way terrain.vsh does for +// blocks: the sky's share, which entity_light.glsl lights again with the sun, the sky and the shadow map, and the +// share torches and other glowing blocks add on top, which takes the torch colour. The vertex's light is the same +// pair of levels a block's is (block, sky; 0 .. 240), so a mob standing on the grass reads the light the grass +// reads. +// Locations 8 to 12 are Etna's own: vanilla's entity and item shaders use 0 to 7. + +#ifndef OIT_ALPHA_ONLY +layout(location = 8) out vec3 etnaModelPos; // camera-relative position, world-oriented (vanilla's Position) +layout(location = 9) out vec3 etnaLightmap; // vanilla's light here, with the held light and the cave light +layout(location = 10) out vec3 etnaTorchLight; // the part of etnaLightmap that block light adds to the sky's +// x: block light level, y: sky light level (both 0 .. 1), z: how much of the light on the sky's side is really sky +// rather than the dimension's ambient floor or night vision (as terrain.vsh's). +layout(location = 11) out vec3 etnaLightLevels; +layout(location = 12) out vec3 etnaModelNormal; // vanilla's Normal: world-oriented in the world and in the hand + +// The same split terrain.vsh makes for a block's vertex (see the comments there), for a vertex of a model at the +// camera-relative position pos, with the vertex's normal and light. The light held in the player's hand counts as +// block light here too, as it does on the blocks around. +void etnaModelLightVertex(vec3 pos, vec3 normal, ivec2 uv2) { + const vec3 LUMA = vec3(0.2126, 0.7152, 0.0722); + etnaModelPos = pos; + etnaModelNormal = normal; + vec2 light = vec2(uv2); + light.x = max(light.x, 16.0 * etnaHeldLevel(pos)); + etnaLightmap = texture(Sampler2, clamp(light / 256.0 + 0.5 / 16.0, vec2(0.5 / 16.0), vec2(15.5 / 16.0))).rgb; + vec3 skyOnly = sample_lightmap(Sampler2, ivec2(0, uv2.y)).rgb; + vec3 floorOnly = sample_lightmap(Sampler2, ivec2(0, 0)).rgb; + etnaTorchLight = max(etnaLightmap - skyOnly, 0.0); + etnaLightmap += etnaCaveLift(uv2, floorOnly); + float skyLuma = dot(skyOnly, LUMA); + float realSky = clamp((skyLuma - dot(floorOnly, LUMA)) / max(skyLuma, 1e-3), 0.0, 1.0); + etnaLightLevels = vec3(clamp(vec2(uv2) / 240.0, 0.0, 1.0), realSky); +} +#endif diff --git a/tools/validate_shaders.py b/tools/validate_shaders.py index b82fac8..4146258 100644 --- a/tools/validate_shaders.py +++ b/tools/validate_shaders.py @@ -5,7 +5,8 @@ injected after the #version/#extension lines. Etna's core shaders (assets/etna/shaders/core) are compiled with every define combination the game builds them with: terrain, clouds and weather with those of the vanilla pipelines Etna copies onto them (terrain has many; clouds vanilla's two and Etna's lit copy; weather -vanilla's and the Improved Transparency stages), the sky with the base defines; each with both depth conventions. In each combination the vertex outputs are checked +vanilla's and the Improved Transparency stages; entity and item those of the entity and item pipelines Etna lights), +the sky with the base defines; each with both depth conventions. In each combination the vertex outputs are checked against the fragment inputs, after glslang's own preprocessor has resolved the #ifdefs. Etna never overrides vanilla's own assets (assets/minecraft): with Etna off the game must look exactly as @@ -102,9 +103,45 @@ {"OIT": "", "OIT_ALPHA_ONLY": "", "OIT_TRANSMITTANCE": ""}, {"OIT": "", "OIT_ALPHA_ONLY": "", "OIT_DEPTH_BOUNDS": ""}, ] -# Define sets each of Etna's core shaders is built with: terrain, clouds and weather take the defines of the -# vanilla pipelines Etna copies onto them (EtnaShadows.copy); any other is built with no defines of its own. -CORE_VARIANTS = {"terrain": TERRAIN_VARIANTS, "clouds": CLOUDS_VARIANTS, "weather": WEATHER_VARIANTS} +# Etna's copies of vanilla's entity and item pipelines (EtnaEntities.java): each vanilla pipeline's own defines, with +# Etna's block (ETNA_SHADOWS), lit for the world or for the first-person hand (ETNA_HAND), and with the shadow +# filter's texelFetch fallback (ETNA_SHADOW_FETCH). Improved Transparency's stages are not copied, but the shaders +# keep vanilla's code for them, so a few are built too. +ENTITY_PIPELINES = [ + {}, # ENTITY_SOLID, ENTITY_SOLID_Z_OFFSET_FORWARD + {"ALPHA_CUTOUT": "0.1"}, # ENTITY_CUTOUT_CULL (chests), ENTITY_TRANSLUCENT_CULL + {"ALPHA_CUTOUT": "0.1", "PER_FACE_LIGHTING": ""}, # ENTITY_CUTOUT, ENTITY_CUTOUT_Z_OFFSET, ENTITY_TRANSLUCENT + {"ALPHA_CUTOUT": "0.1", "NO_OVERLAY": "", "PER_FACE_LIGHTING": ""}, # ARMOR_CUTOUT_NO_CULL, ARMOR_DECAL_CUTOUT_NO_CULL, WOLF_ARMOR_CRACKS + {"ALPHA_CUTOUT": "0.1", "NO_OVERLAY": "", "PER_FACE_LIGHTING": "", "GLINT": ""}, # ARMOR_CUTOUT_NO_CULL_GLINT + {"GLINT": ""}, # ENTITY_SOLID_GLINT + {"NO_OVERLAY": ""}, # BANNER_PATTERN +] +ITEM_PIPELINES = [ + {"ALPHA_CUTOUT": "0.1"}, # ITEM_CUTOUT, ITEM_TRANSLUCENT + {"ALPHA_CUTOUT": "0.1", "GLINT": ""}, # ITEM_CUTOUT_GLINT, ITEM_TRANSLUCENT_GLINT + {"ALPHA_CUTOUT": "0.1", "GLINT": "", "GLINT_SPECIAL": ""}, # ITEM_CUTOUT_GLINT_SPECIAL, ITEM_TRANSLUCENT_GLINT_SPECIAL +] +OIT_STAGES = [ + {"OIT": "", "OIT_ACCUMULATE": ""}, + {"OIT": "", "OIT_ALPHA_ONLY": "", "OIT_TRANSMITTANCE": ""}, + {"OIT": "", "OIT_ALPHA_ONLY": "", "OIT_DEPTH_BOUNDS": ""}, +] + + +def etna_model_variants(pipelines): + lit = [{**p, "ETNA_SHADOWS": "", **extra} for p in pipelines + for extra in ({}, {"ETNA_HAND": ""}, {"ETNA_SHADOW_FETCH": ""}, {"ETNA_HAND": "", "ETNA_SHADOW_FETCH": ""})] + # Vanilla's stage pipelines all test alpha at 0.1. + stages = [{"ALPHA_CUTOUT": "0.1", "ETNA_SHADOWS": "", **s} for s in OIT_STAGES] + return lit + stages + + +ENTITY_VARIANTS = etna_model_variants(ENTITY_PIPELINES) +ITEM_VARIANTS = etna_model_variants(ITEM_PIPELINES) +# Define sets each of Etna's core shaders is built with: terrain, clouds, weather, entity and item take the defines +# of the vanilla pipelines Etna copies onto them (EtnaShadows.copy); any other is built with no defines of its own. +CORE_VARIANTS = {"terrain": TERRAIN_VARIANTS, "clouds": CLOUDS_VARIANTS, "weather": WEATHER_VARIANTS, + "entity": ENTITY_VARIANTS, "item": ITEM_VARIANTS} def vanilla_dir(): From db755a15e7af4fc378dbf3687d0eaae0609243bd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 02:49:26 +0000 Subject: [PATCH 11/24] Light mobs, block entities, items and the hand like the terrain Vanilla's prepared render types draw every entity, block entity and item through one private method. A new optional mixin swaps its pipeline for Etna's copy (entity or item shader, Etna's bind group) while Etna lights the draw, and binds the terrain block, shadow maps and cloud texture with the default uniforms; the terrain's binding moves to EtnaShadows.bindLight for both. Only the level's draws and the first-person hand's are swapped, never the menus', and only classic pipelines (no Improved Transparency stages). New Entity Lighting switch on the Lighting page (default on), saved in etna.properties, and an "entity lighting off" benchmark step. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01F7MajtknSApr1T68EGCPtx --- docs/benchmark.md | 2 +- docs/settings.md | 5 +- .../java/it/cosimo/etna/EtnaBenchmark.java | 8 +- src/main/java/it/cosimo/etna/EtnaConfig.java | 10 +- .../java/it/cosimo/etna/EtnaEntities.java | 225 ++++++++++++++++++ src/main/java/it/cosimo/etna/EtnaFrame.java | 1 + src/main/java/it/cosimo/etna/EtnaShadows.java | 11 +- .../cosimo/etna/gui/EtnaSettingsScreen.java | 3 + .../cosimo/etna/mixin/GameRendererMixin.java | 10 + .../cosimo/etna/mixin/LevelRendererMixin.java | 7 +- .../etna/mixin/PreparedRenderTypeMixin.java | 37 +++ .../resources/assets/etna/lang/en_us.json | 6 +- .../resources/assets/etna/lang/it_it.json | 6 +- src/main/resources/etna.mixins.json | 1 + 14 files changed, 321 insertions(+), 11 deletions(-) create mode 100644 src/main/java/it/cosimo/etna/EtnaEntities.java create mode 100644 src/main/java/it/cosimo/etna/mixin/PreparedRenderTypeMixin.java diff --git a/docs/benchmark.md b/docs/benchmark.md index 5113505..b8bfcb3 100644 --- a/docs/benchmark.md +++ b/docs/benchmark.md @@ -19,7 +19,7 @@ Both tools are on the **General** page of Options > Etna Shaders... * **Performance log**: set it to Summary, and every 5 seconds a line goes to `logs/latest.log` with the frame rate, the frame time and where it goes, on the CPU and on the GPU (each pass of Etna timed on one frame in eight), and the draw calls. -* **Run benchmark**: about 135 seconds, hands off, inside a world. Etna measures itself off, as set, and with each +* **Run benchmark**: about 145 seconds, hands off, inside a world. Etna measures itself off, as set, and with each costly feature off in turn, then writes a table of what each one costs to `logs/latest.log` and puts every setting back as it was. The sun and the weather hold still while it runs, so every step sees the same shadows (the rain the server sent comes back when it ends). If the scene still changes (chunks loading, mobs walking diff --git a/docs/settings.md b/docs/settings.md index 488178c..3909001 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -25,7 +25,7 @@ Start here. Quality sets how much work Etna does, Look sets the colours and mood * **World Resolution**: How many pixels the world is drawn at, as a share of the screen's width and height. Below 100% Etna draws the world smaller and its anti-aliasing builds the full picture back up over a few frames, the way DLSS and FSR do: much faster, a little softer, mostly in motion. 75% draws about half the pixels. The hand, the HUD and menus stay sharp. Needs anti-aliasing, which every Quality but Low has. * **Resolution** *(macOS only)*: Half draws a quarter of the pixels and lets macOS scale the picture up: much faster, but blurry. Applies after restarting the game, and only when Etna is on as the game starts. * **Performance log**: Summary writes a line to latest.log every few seconds while you play: frame rate, frame time and where it goes, on the CPU and on the GPU (timed on one frame in eight, so the frame rate stays the normal one). Off writes nothing. -* **Run benchmark**: Takes about 135 seconds, in a world: Etna tries its settings one at a time, writes what each one costs to latest.log, then puts everything back as it was. Stand still and don't move the mouse: a step disturbed by a camera move or a stutter is measured again, which can add up to about 20 seconds. Turn any frame-rate limiter mod and VSync off first. +* **Run benchmark**: Takes about 145 seconds, in a world: Etna tries its settings one at a time, writes what each one costs to latest.log, then puts everything back as it was. Stand still and don't move the mouse: a step disturbed by a camera move or a stutter is measured again, which can add up to about 20 seconds. Turn any frame-rate limiter mod and VSync off first. ## Mood @@ -65,7 +65,7 @@ grows a little at sunrise and sunset. ## Lighting & Shadows -Sunlight, moonlight, shadows, and the faint light in caves. +Sunlight, moonlight, shadows, the faint light in caves, and the light on mobs and items. * **Shadows**: Real shadows cast by the sun and the moon. * **Shadow Quality**: Detail of the shadow map: higher is sharper and slower. @@ -74,6 +74,7 @@ Sunlight, moonlight, shadows, and the faint light in caves. * **Shadow Darkness**: How dark the shade is next to sunlight. * **Sunlight**: Strength of direct sunlight. * **Cave Light**: Caves and dark rooms are never pitch black: a faint, cool light shows their shape. +* **Entity Lighting** (default On): Mobs, chests, armour, dropped and held items and your hand take the same sunlight, blue shade, shadows and warm torch light as the land around them. Off keeps Minecraft's own flat light on them. Your hand takes the shade of whatever stands over you. Not part of the Look or the Quality. Items and mobs in the inventory and other menus always keep Minecraft's light; with Improved Transparency on, translucent mobs (and a few translucent items) keep it in the world too. ## Surfaces diff --git a/src/main/java/it/cosimo/etna/EtnaBenchmark.java b/src/main/java/it/cosimo/etna/EtnaBenchmark.java index 165ed69..e69a4ed 100644 --- a/src/main/java/it/cosimo/etna/EtnaBenchmark.java +++ b/src/main/java/it/cosimo/etna/EtnaBenchmark.java @@ -30,7 +30,7 @@ * A step whose camera moves or turns, or that hits a frame over 50 ms (a pipeline compile, chunks * loading, a garbage collection), starts measuring again half a second later, up to three tries; only a * few of those in a run, so it stays within about two minutes. The table marks the steps that stayed - * disturbed. Twenty-one steps take about 135 seconds. The run moves on from the start of each frame; a + * disturbed. Twenty-two steps take about 145 seconds. The run moves on from the start of each frame; a * client-tick listener only watches Etna's key. * * The sun holds still for the run, where Etna and the sky read it: in the two minutes a run takes it @@ -160,6 +160,10 @@ private record Step(String name, Role role, Runnable change, Predicate }, base -> base.nightSky != EtnaConfig.NightSky.STARS && base.nightSky != EtnaConfig.NightSky.OFF || base.aurora != EtnaConfig.Aurora.OFF || base.shootingStars != EtnaConfig.ShootingStars.OFF || base.rainbows != EtnaConfig.Rainbows.OFF || base.biomeSkyColor > 0.0F || base.fireflies != EtnaConfig.Fireflies.OFF), + // 0.9.0: mobs, block entities, items and the hand back on vanilla's light (EtnaEntities), so their shadow map + // lookups go too. + new Step("entity lighting off", Role.OTHER, () -> EtnaConfig.entityLighting = false, + base -> base.entityLighting), new Step("rain+bloom fog+nether off", Role.OTHER, () -> { EtnaConfig.rainLook = EtnaConfig.RainLook.VANILLA; EtnaConfig.bloomFog.set(0.0F); @@ -936,6 +940,7 @@ private static final class Snapshot { final EtnaConfig.RainLook rainLook = EtnaConfig.rainLook; final float bloomFog = EtnaConfig.bloomFog.value; final float netherAir = EtnaConfig.netherAir.value; + final boolean entityLighting = EtnaConfig.entityLighting; Snapshot() { for (int i = 0; i < this.look.length; i++) { @@ -979,6 +984,7 @@ void restore() { EtnaConfig.rainLook = this.rainLook; EtnaConfig.bloomFog.value = this.bloomFog; EtnaConfig.netherAir.value = this.netherAir; + EtnaConfig.entityLighting = this.entityLighting; } } } diff --git a/src/main/java/it/cosimo/etna/EtnaConfig.java b/src/main/java/it/cosimo/etna/EtnaConfig.java index 863dfb4..f6ea045 100644 --- a/src/main/java/it/cosimo/etna/EtnaConfig.java +++ b/src/main/java/it/cosimo/etna/EtnaConfig.java @@ -232,6 +232,11 @@ public static void resetSurfaces() { * texel at a time as before 0.6.0 (same picture, slower), in case a graphics driver gets the gather wrong. */ public static boolean shadowGather = true; + /** + * Entity Lighting (Lighting page): mobs, block entities, armour, items and the hand lit like the terrain (EtnaEntities); + * off keeps vanilla's light on them. Not part of the Look or the Quality. + */ + public static boolean entityLighting = true; // 0.8.0 SKY /** The night sky's stars (EtnaSky): none, stars, stars with the Milky Way, or the Milky Way with nebula colours. */ @@ -423,6 +428,7 @@ public static void load() { profiling = choice(props, "profiling", Profiling.OFF); shadowCulling = bool(props, "shadowCulling", true); shadowGather = bool(props, "shadowGather", true); + entityLighting = bool(props, "entityLighting", true); // 0.8.0 MOTES fireflies = choice(props, "fireflies", Fireflies.NORMAL); applyQuality(choice(props, "quality", Quality.HIGH)); @@ -481,6 +487,7 @@ public static void save() { props.setProperty("profiling", profiling.name().toLowerCase(Locale.ROOT)); props.setProperty("shadowCulling", Boolean.toString(shadowCulling)); props.setProperty("shadowGather", Boolean.toString(shadowGather)); + props.setProperty("entityLighting", Boolean.toString(entityLighting)); // 0.8.0 MOTES props.setProperty("fireflies", fireflies.name().toLowerCase(Locale.ROOT)); props.setProperty("quality", quality.name().toLowerCase(Locale.ROOT)); @@ -512,7 +519,8 @@ public static void save() { + " nights and shade read (0: as in 0.7.0), kept whatever the profile. profiling: off | summary (a performance line in" + " latest.log every few seconds). shadowCulling: true | false (false draws every shadow caster, to compare)." + " shadowGather: true | false (false reads the shadow map one texel at a time, as before 0.6.0; applies after" - + " a restart). cloudShadows: 0 .. 1, how much a cloud overhead dims the sun on the land (0: no cloud shadows)." + + " a restart). entityLighting: true | false (false keeps vanilla's light on mobs, chests, items and the hand)." + + " cloudShadows: 0 .. 1, how much a cloud overhead dims the sun on the land (0: no cloud shadows)." + " mist: 0 .. 1, ground and valley mist, night and rain haze, cave murk (0: off). mood: natural | clear_day | trailer" + " | storybook | old_kingdom | ink | lifelike | enchanted | mystic | autumn | pastel, kept whatever the profile." + " moodStrength: 0 .. 1 (0: the look alone). fireflies: off | few | normal" diff --git a/src/main/java/it/cosimo/etna/EtnaEntities.java b/src/main/java/it/cosimo/etna/EtnaEntities.java new file mode 100644 index 0000000..18fe90e --- /dev/null +++ b/src/main/java/it/cosimo/etna/EtnaEntities.java @@ -0,0 +1,225 @@ +package it.cosimo.etna; + +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.Set; + +import com.mojang.blaze3d.pipeline.RenderPipeline; +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.renderpearl.api.commands.RenderPass; +import it.unimi.dsi.fastutil.objects.ReferenceOpenHashSet; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.resources.Identifier; +import org.jspecify.annotations.Nullable; + +/** + * Mobs, block entities, armour, items and the first-person hand lit like the terrain (0.9.0): the golden sun, the + * blue shade of the sky, the shadow map, the clouds' shadows and the warm torch colour, instead of vanilla's two + * fixed lights over its lightmap, which left them looking pasted onto Etna's land. + * + * Every entity, block entity and item draw, in the world, in the hand and in the menus alike, goes through one + * private method of vanilla's prepared render types, which sets the draw's pipeline on its render pass and then + * binds the default uniforms. PreparedRenderTypeMixin swaps that pipeline for Etna's copy of it (pipelineFor) and, + * when it did, binds Etna's terrain block, shadow maps and cloud texture on the pass along with the default + * uniforms (bind), as the shadow-receiving terrain has them (EtnaShadows.bindLight). The copies are vanilla's + * pipelines with Etna's entity or item shader (assets/etna/shaders/core/entity.* and item.*) and Etna's bind group + * added: the same vertex format, blending, depth test, culling and uniform names, so the draw and its routing stay + * vanilla's. + * + * Only draws Etna should light are swapped: while Etna's terrain block is ready this frame (EtnaShadows.lightBlock, + * so Etna is on and in a world), Entity Lighting is on, and the draw is part of the level (from the shadow pass + * hook in LevelRendererMixin until the hand) or of the first-person hand (GameRendererMixin, from right before the + * hand is drawn until the end of renderLevel), never the menus: the inventory's items and mobs, the item atlas and + * picture-in-picture use their own projection and lights, and draw after the level. The hand's copies define + * ETNA_HAND: it is drawn close to the eye with its own projection, so it is lit as if it stood at the camera. + * Only the pipelines in pipelines() are swapped, by identity (the glint variants share their locations with the + * plain ones): the classic ones. Improved Transparency's stage pipelines, emissive parts, eyes, the energy swirl, + * the breeze's wind, the end crystal's beam, the dragon's dissolve, shadows, outlines, the separate glint pass and + * block cracks stay vanilla's. + * + * Both injections are optional (require = 0): if the game changes under them, entities simply keep vanilla's light. + * Nothing is swapped until the binding injection has run once, so a copy is never drawn without its block; if + * neither ever runs while Etna would light entities, that is logged once. A copy that does not compile leaves its + * pipeline vanilla's for the session (until a resource reload), an error leaves all of them vanilla's. + */ +public final class EtnaEntities { + private static final Identifier ENTITY_SHADER = Identifier.fromNamespaceAndPath(EtnaClient.MOD_ID, "core/entity"); + private static final Identifier ITEM_SHADER = Identifier.fromNamespaceAndPath(EtnaClient.MOD_ID, "core/item"); + /** Frames of the level drawn with Entity Lighting active before a hook that never ran is logged. */ + private static final int HOOK_GRACE_FRAMES = 600; + + /** Which part of the frame is being drawn, for which copies, if any, to draw with. */ + private enum Phase { NONE, WORLD, HAND } + + private static Phase phase = Phase.NONE; + /** The vanilla pipelines Etna lights, each with the shader of its copy; made on first use (pipelines). */ + private static @Nullable Map lit; + private static final Map worldCopies = new IdentityHashMap<>(); + private static final Map handCopies = new IdentityHashMap<>(); + /** Vanilla pipelines whose copy did not compile: drawn as vanilla's until the next resource reload. */ + private static final Set failed = new ReferenceOpenHashSet<>(); + /** Set after an error: vanilla's light on every entity for the rest of the session. */ + private static boolean broken; + /** Whether each injection has run at least once: the pipeline swap, and the binding after it. */ + private static boolean swapHookSeen; + private static boolean bindHookSeen; + /** Whether the draw now going on had its pipeline swapped, so bind gives it Etna's block. */ + private static boolean swapped; + private static int framesWithoutHooks; + private static boolean hooksWarned; + + private EtnaEntities() { + } + + /** + * LevelRendererMixin, at the shadow pass hook, before the level's draws: the draws from here to the hand belong to + * the level. Also notices, once, when the injections never run. + */ + public static void beginWorld() { + phase = Phase.WORLD; + if (!hooksWarned && !(swapHookSeen && bindHookSeen) && wanted() && ++framesWithoutHooks > HOOK_GRACE_FRAMES) { + hooksWarned = true; + EtnaClient.LOGGER.warn("Etna: the entity lighting hook did not run (pipeline swap {}, binding {}); entities, items and the hand" + + " keep vanilla's light. A newer Minecraft may have changed the code it needs.", swapHookSeen, bindHookSeen); + } + } + + /** GameRendererMixin, right before the hand is drawn: the draws from here to the end of renderLevel are the hand's. */ + public static void beginHand() { + phase = Phase.HAND; + } + + /** GameRendererMixin, at the end of renderLevel and at the start of every frame: the menus and anything else are vanilla's. */ + public static void endLevel() { + phase = Phase.NONE; + } + + /** Whether Etna lights entities this frame at all: Etna on in a world, its block ready, the setting on. */ + private static boolean wanted() { + return EtnaConfig.entityLighting && !broken && EtnaClient.activeEffect() != null && EtnaShadows.lightBlock() != null; + } + + /** + * PreparedRenderTypeMixin, at the start of a draw: the pipeline to draw with, Etna's copy of {@code vanilla} while + * Etna lights this draw, else {@code vanilla} itself. + */ + public static RenderPipeline pipelineFor(RenderPipeline vanilla) { + swapHookSeen = true; + swapped = false; + if (phase == Phase.NONE || !bindHookSeen || !wanted() || failed.contains(vanilla)) { + return vanilla; + } + try { + Identifier shader = pipelines().get(vanilla); + if (shader == null) { + return vanilla; + } + boolean hand = phase == Phase.HAND; + RenderPipeline copy = (hand ? handCopies : worldCopies).computeIfAbsent(vanilla, p -> makeCopy(p, shader, hand)); + if (RenderSystem.getCompiledPipelineNullable(copy) == null) { + failed.add(vanilla); + EtnaClient.LOGGER.error("Etna: {} did not compile, those entities keep vanilla's light", copy.getLocation()); + return vanilla; + } + swapped = true; + return copy; + } catch (RuntimeException e) { + broken = true; + EtnaClient.LOGGER.error("Etna: lighting entities failed, entities, items and the hand keep vanilla's light for this session", e); + return vanilla; + } + } + + /** + * PreparedRenderTypeMixin, as the draw binds its default uniforms, after its pipeline is set: Etna's block, shadow + * maps and cloud texture for a copy (pipelineFor). They stay bound for the rest of the pass, where pipelines + * without them leave them unread. + */ + public static void bind(RenderPass pass) { + bindHookSeen = true; + if (!swapped) { + return; + } + swapped = false; + if (EtnaShadows.lightBlock() != null) { + EtnaShadows.bindLight(pass); + } + } + + /** + * The vanilla pipelines Etna lights, by identity, with the shader their copies draw with. The classic ones only: + * every entity, block entity and armour pipeline lit by vanilla's lightmap, and the item pipelines. + */ + private static Map pipelines() { + Map table = lit; + if (table == null) { + table = new IdentityHashMap<>(); + for (RenderPipeline p : new RenderPipeline[] { + RenderPipelines.ENTITY_SOLID, + // Item frames. + RenderPipelines.ENTITY_SOLID_Z_OFFSET_FORWARD, + // Chests. + RenderPipelines.ENTITY_CUTOUT_CULL, + RenderPipelines.ENTITY_CUTOUT, + RenderPipelines.ENTITY_CUTOUT_Z_OFFSET, + // The arm, and translucent mobs. + RenderPipelines.ENTITY_TRANSLUCENT, + RenderPipelines.ENTITY_TRANSLUCENT_CULL, + RenderPipelines.ARMOR_CUTOUT_NO_CULL, + RenderPipelines.ARMOR_DECAL_CUTOUT_NO_CULL, + RenderPipelines.WOLF_ARMOR_CRACKS, + RenderPipelines.ARMOR_CUTOUT_NO_CULL_GLINT, + // Tridents and shields. + RenderPipelines.ENTITY_SOLID_GLINT, + RenderPipelines.BANNER_PATTERN + }) { + table.put(p, ENTITY_SHADER); + } + for (RenderPipeline p : new RenderPipeline[] { + RenderPipelines.ITEM_CUTOUT, + RenderPipelines.ITEM_CUTOUT_GLINT, + RenderPipelines.ITEM_CUTOUT_GLINT_SPECIAL, + RenderPipelines.ITEM_TRANSLUCENT, + RenderPipelines.ITEM_TRANSLUCENT_GLINT, + RenderPipelines.ITEM_TRANSLUCENT_GLINT_SPECIAL + }) { + table.put(p, ITEM_SHADER); + } + lit = table; + } + return table; + } + + /** + * Etna's copy of a vanilla entity or item pipeline: everything of vanilla's, drawn with Etna's shader, with Etna's + * block (ETNA_SHADOWS) and its bind group (EtnaShadows.LAYOUT), for the level or for the hand (ETNA_HAND). + */ + private static RenderPipeline makeCopy(RenderPipeline vanilla, Identifier shader, boolean hand) { + RenderPipeline.Builder builder = EtnaShadows.copy(vanilla, true) + .withVertexShader(shader) + .withFragmentShader(shader) + .withShaderDefine("ETNA_SHADOWS"); + if (!EtnaConfig.shadowGather) { + // shadowGather=false in the file: the shadow filter reads one texel at a time, as on the terrain. + builder.withShaderDefine("ETNA_SHADOW_FETCH"); + } + if (hand) { + builder.withShaderDefine("ETNA_HAND"); + } + // The glint variants share their locations with the plain pipelines: their copies get names of their own. + String path = vanilla.getLocation().getPath(); + var flags = vanilla.getShaderDefines().flags(); + if (!path.contains("glint")) { + path += flags.contains("GLINT_SPECIAL") ? "_glint_special" : flags.contains("GLINT") ? "_glint" : ""; + } + return builder + .withBindGroupLayout(EtnaShadows.LAYOUT) + .withLocation(Identifier.fromNamespaceAndPath(EtnaClient.MOD_ID, (hand ? "hand/" : "lit/") + path)) + .build(); + } + + /** EtnaFrame.resourcesReloaded: the shaders were recompiled, so a copy that failed gets another try. */ + static void resourcesReloaded() { + failed.clear(); + } +} diff --git a/src/main/java/it/cosimo/etna/EtnaFrame.java b/src/main/java/it/cosimo/etna/EtnaFrame.java index 6928e5b..e94e88d 100644 --- a/src/main/java/it/cosimo/etna/EtnaFrame.java +++ b/src/main/java/it/cosimo/etna/EtnaFrame.java @@ -308,6 +308,7 @@ public static void resourcesReloaded() { EtnaSky.resourcesReloaded(); EtnaClouds.resourcesReloaded(); EtnaWeather.resourcesReloaded(); + EtnaEntities.resourcesReloaded(); EtnaShadows.resourcesReloaded(); } diff --git a/src/main/java/it/cosimo/etna/EtnaShadows.java b/src/main/java/it/cosimo/etna/EtnaShadows.java index e7440dc..94076cc 100644 --- a/src/main/java/it/cosimo/etna/EtnaShadows.java +++ b/src/main/java/it/cosimo/etna/EtnaShadows.java @@ -1239,13 +1239,22 @@ public static void beginTerrain(RenderPass pass, @Nullable RenderPipeline overri if (!uboReady || override != null || overrideMultidraw != null) { return; } + bindLight(pass); + bindingMainTerrain = true; + } + + /** + * Binds what LAYOUT names on a pass: the two shadow maps, the block and the cloud texture. For the shadow-receiving + * terrain (beginTerrain) and for Etna's lit entities and items (EtnaEntities). Only while the block is ready + * (lightBlock), which also means the maps are there. + */ + static void bindLight(RenderPass pass) { GpuSampler nearest = RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST); pass.setUniform("EtnaShadowSampler", views[primary], nearest); pass.setUniform("EtnaShadowSampler2", views[1 - primary], nearest); pass.setUniform("EtnaShadow", ubo.slice()); // Bilinear, and wrapping round as the clouds repeat. pass.setUniform("EtnaCloudSampler", EtnaClouds.texture(), RenderSystem.getSamplerCache().getRepeat(FilterMode.LINEAR)); - bindingMainTerrain = true; } public static void endTerrain() { diff --git a/src/main/java/it/cosimo/etna/gui/EtnaSettingsScreen.java b/src/main/java/it/cosimo/etna/gui/EtnaSettingsScreen.java index e278eda..518b2fa 100644 --- a/src/main/java/it/cosimo/etna/gui/EtnaSettingsScreen.java +++ b/src/main/java/it/cosimo/etna/gui/EtnaSettingsScreen.java @@ -104,6 +104,7 @@ protected void init() { EtnaConfig.shoreFoam.set(EtnaConfig.SHORE_FOAM_DEFAULT); EtnaConfig.waterGlitter.set(EtnaConfig.WATER_GLITTER_DEFAULT); EtnaConfig.resetSky(); + EtnaConfig.entityLighting = true; this.open(this.page); }).bounds(8, this.height - 28, SIDEBAR_WIDTH - 16, 20).build()); @@ -235,6 +236,8 @@ private void lightingPage() { this.look(EtnaConfig.shadowDarkness, EtnaSettingsScreen::percent); this.look(EtnaConfig.sunlight, EtnaSettingsScreen::percent); this.look(EtnaConfig.caveLight, EtnaSettingsScreen::percentOrOff); + // 0.9.0: not part of the Look or the Quality. + this.cycle("entityLighting", () -> onOff(EtnaConfig.entityLighting), () -> EtnaConfig.entityLighting = !EtnaConfig.entityLighting); } private void surfacesPage() { diff --git a/src/main/java/it/cosimo/etna/mixin/GameRendererMixin.java b/src/main/java/it/cosimo/etna/mixin/GameRendererMixin.java index f067bd9..f4eea20 100644 --- a/src/main/java/it/cosimo/etna/mixin/GameRendererMixin.java +++ b/src/main/java/it/cosimo/etna/mixin/GameRendererMixin.java @@ -5,6 +5,7 @@ import com.mojang.blaze3d.pipeline.RenderTarget; import com.mojang.blaze3d.resource.CrossFrameResourcePool; import it.cosimo.etna.EtnaClient; +import it.cosimo.etna.EtnaEntities; import it.cosimo.etna.EtnaFrame; import it.cosimo.etna.EtnaGpuTimer; import it.cosimo.etna.EtnaScale; @@ -40,6 +41,9 @@ * With a render scale below 1 (EtnaScale), mainRenderTarget() hands out Etna's smaller target from the start of * the level until the world chain has written the full-size picture, so the level is drawn smaller; the hand and * everything after it use the full-size target, which this class reads through the field. + * + * Etna's entity lighting (EtnaEntities) learns here which draws are the hand's: from right before the hand is drawn + * to the end of renderLevel. Everything after that (the menus) keeps vanilla's light. */ @Mixin(GameRenderer.class) public abstract class GameRendererMixin { @@ -70,6 +74,8 @@ public abstract class GameRendererMixin { @Inject(method = "render", at = @At("HEAD")) private void etna$frameStart(CallbackInfo ci) { EtnaScale.beginFrame(Minecraft.getInstance().level != null); + // Nothing drawn before the level is the level's, in case last frame's renderLevel ended early. + EtnaEntities.endLevel(); } @Inject(method = "renderLevel", at = @At("HEAD")) @@ -81,6 +87,8 @@ public abstract class GameRendererMixin { private void etna$levelEnd(CallbackInfo ci) { // Also here in case the hand's hook below never ran: nothing after the level may draw into the smaller target. EtnaScale.endLevel(); + // The menus draw their items and mobs with vanilla's light. + EtnaEntities.endLevel(); } @Inject(method = "mainRenderTarget", at = @At("HEAD"), cancellable = true) @@ -120,6 +128,8 @@ public abstract class GameRendererMixin { EtnaScale.endLevel(); } EtnaGpuTimer.mark(EtnaGpuTimer.Mark.WORLD_END); + // The hand is lit as it stands in the world, at the camera (EtnaEntities). + EtnaEntities.beginHand(); } @Redirect( diff --git a/src/main/java/it/cosimo/etna/mixin/LevelRendererMixin.java b/src/main/java/it/cosimo/etna/mixin/LevelRendererMixin.java index c603155..e6d0b97 100644 --- a/src/main/java/it/cosimo/etna/mixin/LevelRendererMixin.java +++ b/src/main/java/it/cosimo/etna/mixin/LevelRendererMixin.java @@ -3,6 +3,7 @@ import com.mojang.blaze3d.resource.GraphicsResourceAllocator; import com.mojang.renderpearl.api.buffers.GpuBufferSlice; import it.cosimo.etna.EtnaBenchmark; +import it.cosimo.etna.EtnaEntities; import it.cosimo.etna.EtnaGpuTimer; import it.cosimo.etna.EtnaLook; import it.cosimo.etna.EtnaMotes; @@ -26,7 +27,9 @@ * pass open. Each injection tells the shadow pass which of vanilla's * two ways of preparing the draws is in use, so it prepares its own the same way. The GPU timer brackets * the shadow pass here too, as its timestamps must sit outside any render pass (only while the performance - * log or the benchmark runs; otherwise each mark is one static check). + * log or the benchmark runs; otherwise each mark is one static check). From here on the frame's draws belong to the + * level, which Etna's entity lighting needs to know (EtnaEntities): it lights the level's entities and the hand, never + * the menus. */ @Mixin(LevelRenderer.class) public abstract class LevelRendererMixin { @@ -53,6 +56,7 @@ public abstract class LevelRendererMixin { EtnaGpuTimer.mark(EtnaGpuTimer.Mark.SHADOW_START); EtnaShadows.prepareFrame((LevelRenderer) (Object) this, levelRenderState, fogBuffer, true); EtnaGpuTimer.mark(EtnaGpuTimer.Mark.SHADOW_END); + EtnaEntities.beginWorld(); EtnaMotes.upload(levelRenderState); EtnaLook.upload(); } @@ -76,6 +80,7 @@ public abstract class LevelRendererMixin { EtnaGpuTimer.mark(EtnaGpuTimer.Mark.SHADOW_START); EtnaShadows.prepareFrame((LevelRenderer) (Object) this, levelRenderState, fogBuffer, false); EtnaGpuTimer.mark(EtnaGpuTimer.Mark.SHADOW_END); + EtnaEntities.beginWorld(); EtnaMotes.upload(levelRenderState); EtnaLook.upload(); } diff --git a/src/main/java/it/cosimo/etna/mixin/PreparedRenderTypeMixin.java b/src/main/java/it/cosimo/etna/mixin/PreparedRenderTypeMixin.java new file mode 100644 index 0000000..32da8bc --- /dev/null +++ b/src/main/java/it/cosimo/etna/mixin/PreparedRenderTypeMixin.java @@ -0,0 +1,37 @@ +package it.cosimo.etna.mixin; + +import com.mojang.blaze3d.pipeline.RenderPipeline; +import com.mojang.renderpearl.api.commands.RenderPass; +import it.cosimo.etna.EtnaEntities; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.ModifyArg; +import org.spongepowered.asm.mixin.injection.ModifyVariable; + +/** + * Lights entities, block entities, armour, items and the hand like the terrain (EtnaEntities): the private method + * every one of their draws goes through draws with Etna's copy of its pipeline while Etna lights it, and binds + * Etna's terrain block, shadow maps and cloud texture on the pass together with the default uniforms, after the + * pipeline is set. Both are optional (require = 0): if the game's code changes under them, entities keep vanilla's + * light and the rest of Etna carries on. The class is named rather than referenced, as Etna needs none of its API. + */ +@Mixin(targets = "net.minecraft.client.renderer.rendertype.PreparedRenderType") +public abstract class PreparedRenderTypeMixin { + @ModifyVariable(method = "draw", at = @At("HEAD"), argsOnly = true, require = 0) + private RenderPipeline etna$litPipeline(RenderPipeline renderPipeline) { + return EtnaEntities.pipelineFor(renderPipeline); + } + + @ModifyArg( + method = "draw", + at = @At( + value = "INVOKE", + target = "Lcom/mojang/blaze3d/systems/RenderSystem;bindDefaultUniforms(Lcom/mojang/renderpearl/api/commands/RenderPass;)V" + ), + require = 0 + ) + private RenderPass etna$bindLight(RenderPass renderPass) { + EtnaEntities.bind(renderPass); + return renderPass; + } +} diff --git a/src/main/resources/assets/etna/lang/en_us.json b/src/main/resources/assets/etna/lang/en_us.json index c6e571d..efd44d5 100644 --- a/src/main/resources/assets/etna/lang/en_us.json +++ b/src/main/resources/assets/etna/lang/en_us.json @@ -5,7 +5,7 @@ "etna.options.page.general": "General", "etna.options.page.general.desc": "Start here. Quality sets how much work Etna does, Look sets the colours and mood; the other pages fine-tune them. Every change shows right away. In game, F7 turns Etna on and off.", "etna.options.page.lighting": "Lighting & Shadows", - "etna.options.page.lighting.desc": "Sunlight, moonlight, shadows, and the faint light in caves.", + "etna.options.page.lighting.desc": "Sunlight, moonlight, shadows, the faint light in caves, and the light on mobs and items.", "etna.options.page.effects": "Effects", "etna.options.page.effects.desc": "Anti-aliasing, ambient occlusion, glow and sharpening.", "etna.options.page.colors": "Colors", @@ -106,6 +106,8 @@ "etna.options.page.water.desc": "How water looks from above and below: how far you see under water, its colour, and the light dancing on the seabed.", "etna.options.caveLight": "Cave Light", "etna.options.caveLight.desc": "Caves and dark rooms are never pitch black: a faint, cool light shows their shape.", + "etna.options.entityLighting": "Entity Lighting", + "etna.options.entityLighting.desc": "Mobs, chests, armour, dropped and held items and your hand take the same sunlight, blue shade, shadows and warm torch light as the land around them. Off keeps Minecraft's own flat light on them.", "etna.options.emission": "Glowing Blocks", "etna.options.emission.desc": "Lava, glowstone, sea lanterns and lanterns glow in the dark, brightly enough to bloom, and by day their glowing parts stay lit on the shaded sides too. Needs Smooth Lighting.", "etna.options.underwaterVisibility": "Underwater Visibility", @@ -120,7 +122,7 @@ "etna.options.profiling.desc": "Summary writes a line to latest.log every few seconds while you play: frame rate, frame time and where it goes, on the CPU and on the GPU (timed on one frame in eight, so the frame rate stays the normal one). Off writes nothing.", "etna.options.profiling.summary": "Summary", "etna.options.benchmark": "Run benchmark", - "etna.options.benchmark.desc": "Takes about 135 seconds, in a world: Etna tries its settings one at a time, writes what each one costs to latest.log, then puts everything back as it was. Stand still and don't move the mouse: a step disturbed by a camera move or a stutter is measured again, which can add up to about 20 seconds. Turn any frame-rate limiter mod and VSync off first.", + "etna.options.benchmark.desc": "Takes about 145 seconds, in a world: Etna tries its settings one at a time, writes what each one costs to latest.log, then puts everything back as it was. Stand still and don't move the mouse: a step disturbed by a camera move or a stutter is measured again, which can add up to about 20 seconds. Turn any frame-rate limiter mod and VSync off first.", "etna.benchmark.start": "Etna benchmark: stand still and don't move the mouse for about %s s; turn the frame-rate limiter mod off first.", "etna.benchmark.done": "Etna benchmark finished: the results are in latest.log.", "etna.benchmark.unsteady": "Etna benchmark finished, but the scene changed during the run: the results in latest.log are rough. Run it again, standing still, once the chunks around have loaded.", diff --git a/src/main/resources/assets/etna/lang/it_it.json b/src/main/resources/assets/etna/lang/it_it.json index 2d7f6f5..df8f1a5 100644 --- a/src/main/resources/assets/etna/lang/it_it.json +++ b/src/main/resources/assets/etna/lang/it_it.json @@ -5,7 +5,7 @@ "etna.options.page.general": "Generale", "etna.options.page.general.desc": "Inizia da qui. Qualità decide quanto lavoro fa Etna, Stile decide colori e atmosfera; le altre pagine li rifiniscono. Ogni modifica si vede subito. In gioco, F7 accende e spegne Etna.", "etna.options.page.lighting": "Luce e ombre", - "etna.options.page.lighting.desc": "Luce del sole e della luna, ombre e la luce tenue nelle grotte.", + "etna.options.page.lighting.desc": "Luce del sole e della luna, ombre, la luce tenue nelle grotte e la luce su creature e oggetti.", "etna.options.page.effects": "Effetti", "etna.options.page.effects.desc": "Anti-aliasing, occlusione ambientale, bagliore e nitidezza.", "etna.options.page.colors": "Colori", @@ -106,6 +106,8 @@ "etna.options.page.water.desc": "Com'è l'acqua vista da sopra e da sotto: quanto lontano si vede sott'acqua, il suo colore e la luce che danza sul fondale.", "etna.options.caveLight": "Luce nelle grotte", "etna.options.caveLight.desc": "Grotte e stanze buie non sono mai nere come la pece: una luce tenue e fredda ne mostra la forma.", + "etna.options.entityLighting": "Luce sulle entità", + "etna.options.entityLighting.desc": "Creature, bauli, armature, oggetti a terra e in mano e la tua mano prendono la stessa luce del sole, la stessa ombra azzurra, le stesse ombre e la stessa luce calda delle torce del terreno intorno. Disattivato lascia su di loro la luce piatta di Minecraft.", "etna.options.emission": "Blocchi luminosi", "etna.options.emission.desc": "Lava, luminite, lanterne marine e lanterne brillano al buio, abbastanza da fare un alone, e di giorno le loro parti luminose restano accese anche sui lati in ombra. Richiede la Luce soffusa.", "etna.options.underwaterVisibility": "Visibilità sott'acqua", @@ -120,7 +122,7 @@ "etna.options.profiling.desc": "Con Riepilogo, mentre giochi, ogni pochi secondi scrive una riga in latest.log: frame al secondo, durata dei frame e dove va il tempo, sulla CPU e sulla GPU (misurata su un frame ogni otto, così gli fps restano quelli normali). Con No non scrive nulla.", "etna.options.profiling.summary": "Riepilogo", "etna.options.benchmark": "Avvia benchmark", - "etna.options.benchmark.desc": "Dura circa 135 secondi, in un mondo: Etna prova le sue impostazioni una alla volta, scrive in latest.log quanto costa ognuna e poi rimette tutto com'era. Stai fermo e non muovere il mouse: un passo disturbato da un movimento della visuale o da uno scatto viene misurato di nuovo, il che può aggiungere fino a una ventina di secondi. Prima disattiva la mod che limita i frame e il Sincronismo verticale.", + "etna.options.benchmark.desc": "Dura circa 145 secondi, in un mondo: Etna prova le sue impostazioni una alla volta, scrive in latest.log quanto costa ognuna e poi rimette tutto com'era. Stai fermo e non muovere il mouse: un passo disturbato da un movimento della visuale o da uno scatto viene misurato di nuovo, il che può aggiungere fino a una ventina di secondi. Prima disattiva la mod che limita i frame e il Sincronismo verticale.", "etna.benchmark.start": "Benchmark di Etna: stai fermo e non muovere il mouse per circa %s s; prima disattiva la mod che limita i frame al secondo.", "etna.benchmark.done": "Benchmark di Etna finito: i risultati sono in latest.log.", "etna.benchmark.unsteady": "Benchmark di Etna finito, ma la scena è cambiata durante la prova: i risultati in latest.log sono approssimativi. Rifallo stando fermo, quando i chunk intorno sono caricati.", diff --git a/src/main/resources/etna.mixins.json b/src/main/resources/etna.mixins.json index 2972a15..fe5279d 100644 --- a/src/main/resources/etna.mixins.json +++ b/src/main/resources/etna.mixins.json @@ -16,6 +16,7 @@ "OptionsScreenMixin", "PostChainMixin", "PostPassMixin", + "PreparedRenderTypeMixin", "SkyRendererMixin", "VulkanCommandEncoderMixin", "VulkanRenderPassMixin", From 5fe626845530b9d22516a11b1c577d43e2c12ef7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 02:49:50 +0000 Subject: [PATCH 12/24] Changelog and credits for the entity lighting Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01F7MajtknSApr1T68EGCPtx --- CHANGELOG.md | 1 + CREDITS.md | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 207a7c8..4bce68f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Every release is for Minecraft 26.4-snapshot-3 with Fabric. Versions: the middle Faster with World Resolution, and Etna's own anti-aliasing. +* **Mobs, chests, armour, items and your hand in Etna's light.** They kept Minecraft's flat two-light shading while the land around them had Etna's golden sun, blue shade, shadows and warm torches, so they looked pasted in. Now they are lit the way the land is: the sun and the sky's colour on each face, the shadow map and the clouds' shadows, the warm torch light, the cave light and the distance haze, so a mob under a tree stands in its shade and a white block entity is as bright as the white ground it stands on. Your hand takes the shade of whatever stands over you. Enchantment glints, the hurt flash and the fog stay as they were, and the inventory and other menus keep Minecraft's own light. Entity Lighting (new, Lighting & Shadows page) turns it off; the benchmark has a step for it. With Improved Transparency on, translucent mobs and items keep Minecraft's light for now. * **World Resolution (new, General page, 50 to 100%).** Draws the world at fewer pixels and builds the full-size picture back up in Etna's anti-aliasing, which moves the camera by a different fraction of a pixel every frame and puts the frames together at full size. Most of Etna's cost is per pixel, so at 75% the world shades a little over half the pixels. Textures keep their full-size detail, and the hand, the menus and the HUD stay sharp at full size. Needs anti-aliasing, which every Quality but Low has. The benchmark has a step for it. * **New anti-aliasing, Etna's own.** Steadier and sharper than 0.8's: in Etna's test bench about a third less flicker and error, edges closer to a supersampled picture, and less trailing behind moving things. Glints and sparkles brighter than white no longer flash through it. * **No code from other shader packs any more.** The anti-aliasing and the light shafts' pixel order were the last parts from Photon Shaders; with 0.8.1's own shadow warp and water Fresnel, Etna is now entirely its own code (and Minecraft's shaders it builds on), so it can go on Modrinth and CurseForge. `LICENSE-PHOTON.txt` is gone. diff --git a/CREDITS.md b/CREDITS.md index 751645b..489d75a 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -1,8 +1,8 @@ # Credits Etna is original code under the MIT licence (`LICENSE`), with these exceptions: the sky's star hash is David -Hoskins's, under the MIT licence; and the terrain and cloud shaders are based on Minecraft's own, which Etna's -licence doesn't cover. No code from other shader packs is used. +Hoskins's, under the MIT licence; and the terrain, cloud, weather, entity and item shaders are based on Minecraft's +own, which Etna's licence doesn't cover. No code from other shader packs is used. ## Code ported from other projects @@ -10,7 +10,7 @@ licence doesn't cover. No code from other shader packs is used. |---|---|---| | `src/main/resources/assets/etna/shaders/core/sky.fsh`: `hash33` (where the stars sit, how bright they are, their colour) | "Hash without Sine" by David Hoskins ([shadertoy.com/view/4djSRW](https://www.shadertoy.com/view/4djSRW)), `hash33`, unchanged | MIT, Copyright (c) 2014 David Hoskins, licence text as in `LICENSE` | | `src/main/resources/assets/etna/shaders/core/sky.vsh`: `hash41` (when and where a shooting star flares, which way it falls) | "Hash without Sine" by David Hoskins ([shadertoy.com/view/4djSRW](https://www.shadertoy.com/view/4djSRW)), `hash41`, unchanged | MIT, Copyright (c) 2014 David Hoskins, licence text as in `LICENSE` | -| `src/main/resources/assets/etna/shaders/core/terrain.vsh`, `terrain.fsh`, `clouds.vsh`, `clouds.fsh`, `weather.vsh`, `weather.fsh` | Minecraft's vanilla `terrain`, `clouds` and `particle` core shaders, which Etna replaces while it is on and extends (`weather` is made from `particle`, which vanilla draws rain and snow with) | Based on Minecraft's vanilla shader, (c) Mojang AB, not covered by Etna's MIT licence | +| `src/main/resources/assets/etna/shaders/core/terrain.vsh`, `terrain.fsh`, `clouds.vsh`, `clouds.fsh`, `weather.vsh`, `weather.fsh`, `entity.vsh`, `entity.fsh`, `item.vsh`, `item.fsh` | Minecraft's vanilla `terrain`, `clouds`, `particle`, `entity` and `item` core shaders, which Etna replaces while it is on and extends (`weather` is made from `particle`, which vanilla draws rain and snow with; `entity` and `item` keep everything of vanilla's but the light) | Based on Minecraft's vanilla shader, (c) Mojang AB, not covered by Etna's MIT licence | ## Techniques Etna's own code follows From c90ccf38da9ff88439a024ada1b6af07c45ebeaa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 02:54:00 +0000 Subject: [PATCH 13/24] EtnaShadows: a benchmark hook that holds a fade at its middle The benchmark holds the sun, so no sun-step fade happens during a run and its table never shows what the fades cost in play (every sunlit pixel filtering both maps for part of the time). benchmarkFade, set and put back by EtnaBenchmark, keeps a fade running along the held direction, held at its middle once the second map is drawn: both maps drawn in turn and read half and half, as in a real fade. Off by default; nothing else changes. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Xzo7zbTpyU7a3y3XQNbw1n --- src/main/java/it/cosimo/etna/EtnaShadows.java | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/main/java/it/cosimo/etna/EtnaShadows.java b/src/main/java/it/cosimo/etna/EtnaShadows.java index e7440dc..6196634 100644 --- a/src/main/java/it/cosimo/etna/EtnaShadows.java +++ b/src/main/java/it/cosimo/etna/EtnaShadows.java @@ -266,6 +266,13 @@ public boolean bilinear() { */ private static int primary; private static float secondaryWeight; + /** + * Set by the benchmark (EtnaBenchmark owns it and puts it back): while true, chooseMap keeps a fade running, + * held at its middle, along the held direction. Both maps are drawn in turn and every sunlit pixel reads both, + * as in a real fade, so the benchmark can measure what the fades cost while the sun moves: it holds the sun, + * so no real fade happens during a run. + */ + public static boolean benchmarkFade; private static final Matrix4f primaryMatrix = new Matrix4f(); private static final Matrix4f secondaryMatrix = new Matrix4f(); /** @@ -534,8 +541,19 @@ private static int chooseMap(Vector3f light, Vec3 cam) { drawSlack = 0.0F; return base; } + if (benchmarkFade && !fading) { + // A fade to the direction already held, started as a real one is (the held map drawn once more). + targetDir.set(baseDir); + fading = true; + fade = 0.0F; + mapDrawn[other] = false; + drawSlack = FADE_SLACK; + return base; + } if (fading) { - fade += dt / fadeSeconds; + // The benchmark's fade stays at its start until the other map is drawn (as a real one does in its + // first frame), then at its middle, where both maps count the same. + fade = benchmarkFade ? (mapDrawn[other] ? 0.5F : 0.0F) : fade + dt / fadeSeconds; // The map drawn last frame is read again this frame: unless it still covers the view, and the old // map still counts (MIN_MIX), the fade ends here. if (fade < 1.0F && smoothstep(0.0F, 1.0F, fade) < 1.0F - MIN_MIX && covers(lastDrawn)) { From a92e549096862c7ecae0443b0e1839c0fc9b6bd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 02:55:13 +0000 Subject: [PATCH 14/24] Benchmark: a step that holds a shadow map fade at its middle The benchmark holds the sun, so the fade between shadow maps as the sun moves never ran in it. "shadow map fade" turns on EtnaShadows.benchmarkFade for its step, so the table shows what sunrise and sunset add. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01F7MajtknSApr1T68EGCPtx --- docs/benchmark.md | 2 +- docs/settings.md | 2 +- src/main/java/it/cosimo/etna/EtnaBenchmark.java | 9 ++++++++- src/main/resources/assets/etna/lang/en_us.json | 2 +- src/main/resources/assets/etna/lang/it_it.json | 2 +- 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/benchmark.md b/docs/benchmark.md index 5113505..b8bfcb3 100644 --- a/docs/benchmark.md +++ b/docs/benchmark.md @@ -19,7 +19,7 @@ Both tools are on the **General** page of Options > Etna Shaders... * **Performance log**: set it to Summary, and every 5 seconds a line goes to `logs/latest.log` with the frame rate, the frame time and where it goes, on the CPU and on the GPU (each pass of Etna timed on one frame in eight), and the draw calls. -* **Run benchmark**: about 135 seconds, hands off, inside a world. Etna measures itself off, as set, and with each +* **Run benchmark**: about 145 seconds, hands off, inside a world. Etna measures itself off, as set, and with each costly feature off in turn, then writes a table of what each one costs to `logs/latest.log` and puts every setting back as it was. The sun and the weather hold still while it runs, so every step sees the same shadows (the rain the server sent comes back when it ends). If the scene still changes (chunks loading, mobs walking diff --git a/docs/settings.md b/docs/settings.md index 488178c..72785c4 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -25,7 +25,7 @@ Start here. Quality sets how much work Etna does, Look sets the colours and mood * **World Resolution**: How many pixels the world is drawn at, as a share of the screen's width and height. Below 100% Etna draws the world smaller and its anti-aliasing builds the full picture back up over a few frames, the way DLSS and FSR do: much faster, a little softer, mostly in motion. 75% draws about half the pixels. The hand, the HUD and menus stay sharp. Needs anti-aliasing, which every Quality but Low has. * **Resolution** *(macOS only)*: Half draws a quarter of the pixels and lets macOS scale the picture up: much faster, but blurry. Applies after restarting the game, and only when Etna is on as the game starts. * **Performance log**: Summary writes a line to latest.log every few seconds while you play: frame rate, frame time and where it goes, on the CPU and on the GPU (timed on one frame in eight, so the frame rate stays the normal one). Off writes nothing. -* **Run benchmark**: Takes about 135 seconds, in a world: Etna tries its settings one at a time, writes what each one costs to latest.log, then puts everything back as it was. Stand still and don't move the mouse: a step disturbed by a camera move or a stutter is measured again, which can add up to about 20 seconds. Turn any frame-rate limiter mod and VSync off first. +* **Run benchmark**: Takes about 145 seconds, in a world: Etna tries its settings one at a time, writes what each one costs to latest.log, then puts everything back as it was. Stand still and don't move the mouse: a step disturbed by a camera move or a stutter is measured again, which can add up to about 20 seconds. Turn any frame-rate limiter mod and VSync off first. ## Mood diff --git a/src/main/java/it/cosimo/etna/EtnaBenchmark.java b/src/main/java/it/cosimo/etna/EtnaBenchmark.java index 165ed69..eea7a67 100644 --- a/src/main/java/it/cosimo/etna/EtnaBenchmark.java +++ b/src/main/java/it/cosimo/etna/EtnaBenchmark.java @@ -30,7 +30,7 @@ * A step whose camera moves or turns, or that hits a frame over 50 ms (a pipeline compile, chunks * loading, a garbage collection), starts measuring again half a second later, up to three tries; only a * few of those in a run, so it stays within about two minutes. The table marks the steps that stayed - * disturbed. Twenty-one steps take about 135 seconds. The run moves on from the start of each frame; a + * disturbed. Twenty-two steps take about 145 seconds. The run moves on from the start of each frame; a * client-tick listener only watches Etna's key. * * The sun holds still for the run, where Etna and the sky read it: in the two minutes a run takes it @@ -109,6 +109,11 @@ private record Step(String name, Role role, Runnable change, Predicate EtnaConfig.lightRays.set(0.0F); EtnaConfig.mist.set(0.0F); }, base -> base.look(EtnaConfig.lightRays) > 0.0F || base.look(EtnaConfig.mist) > 0.0F), + // The benchmark holds the sun, so the fade from one shadow map to the next as the sun moves never runs by + // itself: this step holds one at its middle, where every sunlit pixel reads both maps (EtnaShadows.benchmarkFade). + // It costs more than "as set", unlike the others: what playing near sunrise and sunset adds. + new Step("shadow map fade", Role.SHADOWS, () -> EtnaShadows.benchmarkFade = true, + base -> base.shadows), new Step("hard shadow edges", Role.SHADOWS, () -> EtnaConfig.shadowSoftness.set(0.25F), base -> base.look(EtnaConfig.shadowSoftness) != 0.25F), new Step("shadow map 2048", Role.SHADOWS, () -> EtnaConfig.shadowResolution = 2048, @@ -958,6 +963,8 @@ void restore() { EtnaConfig.antiAliasing = this.antiAliasing; EtnaConfig.shadowCulling = this.shadowCulling; EtnaConfig.renderScale = this.renderScale; + // Never on outside its own step. + EtnaShadows.benchmarkFade = false; for (int i = 0; i < this.look.length; i++) { // Exactly the old value: Setting.set would clamp, and these came from the same ranges anyway. EtnaConfig.LOOK.get(i).value = this.look[i]; diff --git a/src/main/resources/assets/etna/lang/en_us.json b/src/main/resources/assets/etna/lang/en_us.json index c6e571d..94482cd 100644 --- a/src/main/resources/assets/etna/lang/en_us.json +++ b/src/main/resources/assets/etna/lang/en_us.json @@ -120,7 +120,7 @@ "etna.options.profiling.desc": "Summary writes a line to latest.log every few seconds while you play: frame rate, frame time and where it goes, on the CPU and on the GPU (timed on one frame in eight, so the frame rate stays the normal one). Off writes nothing.", "etna.options.profiling.summary": "Summary", "etna.options.benchmark": "Run benchmark", - "etna.options.benchmark.desc": "Takes about 135 seconds, in a world: Etna tries its settings one at a time, writes what each one costs to latest.log, then puts everything back as it was. Stand still and don't move the mouse: a step disturbed by a camera move or a stutter is measured again, which can add up to about 20 seconds. Turn any frame-rate limiter mod and VSync off first.", + "etna.options.benchmark.desc": "Takes about 145 seconds, in a world: Etna tries its settings one at a time, writes what each one costs to latest.log, then puts everything back as it was. Stand still and don't move the mouse: a step disturbed by a camera move or a stutter is measured again, which can add up to about 20 seconds. Turn any frame-rate limiter mod and VSync off first.", "etna.benchmark.start": "Etna benchmark: stand still and don't move the mouse for about %s s; turn the frame-rate limiter mod off first.", "etna.benchmark.done": "Etna benchmark finished: the results are in latest.log.", "etna.benchmark.unsteady": "Etna benchmark finished, but the scene changed during the run: the results in latest.log are rough. Run it again, standing still, once the chunks around have loaded.", diff --git a/src/main/resources/assets/etna/lang/it_it.json b/src/main/resources/assets/etna/lang/it_it.json index 2d7f6f5..c83da71 100644 --- a/src/main/resources/assets/etna/lang/it_it.json +++ b/src/main/resources/assets/etna/lang/it_it.json @@ -120,7 +120,7 @@ "etna.options.profiling.desc": "Con Riepilogo, mentre giochi, ogni pochi secondi scrive una riga in latest.log: frame al secondo, durata dei frame e dove va il tempo, sulla CPU e sulla GPU (misurata su un frame ogni otto, così gli fps restano quelli normali). Con No non scrive nulla.", "etna.options.profiling.summary": "Riepilogo", "etna.options.benchmark": "Avvia benchmark", - "etna.options.benchmark.desc": "Dura circa 135 secondi, in un mondo: Etna prova le sue impostazioni una alla volta, scrive in latest.log quanto costa ognuna e poi rimette tutto com'era. Stai fermo e non muovere il mouse: un passo disturbato da un movimento della visuale o da uno scatto viene misurato di nuovo, il che può aggiungere fino a una ventina di secondi. Prima disattiva la mod che limita i frame e il Sincronismo verticale.", + "etna.options.benchmark.desc": "Dura circa 145 secondi, in un mondo: Etna prova le sue impostazioni una alla volta, scrive in latest.log quanto costa ognuna e poi rimette tutto com'era. Stai fermo e non muovere il mouse: un passo disturbato da un movimento della visuale o da uno scatto viene misurato di nuovo, il che può aggiungere fino a una ventina di secondi. Prima disattiva la mod che limita i frame e il Sincronismo verticale.", "etna.benchmark.start": "Benchmark di Etna: stai fermo e non muovere il mouse per circa %s s; prima disattiva la mod che limita i frame al secondo.", "etna.benchmark.done": "Benchmark di Etna finito: i risultati sono in latest.log.", "etna.benchmark.unsteady": "Benchmark di Etna finito, ma la scena è cambiata durante la prova: i risultati in latest.log sono approssimativi. Rifallo stando fermo, quando i chunk intorno sono caricati.", From 4d5ca756b34b2e609913ac8856f5887be1ec8a49 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 02:57:43 +0000 Subject: [PATCH 15/24] Docs: the benchmark now has 23 steps and takes about 150 seconds Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01F7MajtknSApr1T68EGCPtx --- docs/benchmark.md | 2 +- docs/settings.md | 2 +- src/main/java/it/cosimo/etna/EtnaBenchmark.java | 2 +- src/main/resources/assets/etna/lang/en_us.json | 2 +- src/main/resources/assets/etna/lang/it_it.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/benchmark.md b/docs/benchmark.md index b8bfcb3..7247c9b 100644 --- a/docs/benchmark.md +++ b/docs/benchmark.md @@ -19,7 +19,7 @@ Both tools are on the **General** page of Options > Etna Shaders... * **Performance log**: set it to Summary, and every 5 seconds a line goes to `logs/latest.log` with the frame rate, the frame time and where it goes, on the CPU and on the GPU (each pass of Etna timed on one frame in eight), and the draw calls. -* **Run benchmark**: about 145 seconds, hands off, inside a world. Etna measures itself off, as set, and with each +* **Run benchmark**: about 150 seconds, hands off, inside a world. Etna measures itself off, as set, and with each costly feature off in turn, then writes a table of what each one costs to `logs/latest.log` and puts every setting back as it was. The sun and the weather hold still while it runs, so every step sees the same shadows (the rain the server sent comes back when it ends). If the scene still changes (chunks loading, mobs walking diff --git a/docs/settings.md b/docs/settings.md index 3909001..9a4976e 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -25,7 +25,7 @@ Start here. Quality sets how much work Etna does, Look sets the colours and mood * **World Resolution**: How many pixels the world is drawn at, as a share of the screen's width and height. Below 100% Etna draws the world smaller and its anti-aliasing builds the full picture back up over a few frames, the way DLSS and FSR do: much faster, a little softer, mostly in motion. 75% draws about half the pixels. The hand, the HUD and menus stay sharp. Needs anti-aliasing, which every Quality but Low has. * **Resolution** *(macOS only)*: Half draws a quarter of the pixels and lets macOS scale the picture up: much faster, but blurry. Applies after restarting the game, and only when Etna is on as the game starts. * **Performance log**: Summary writes a line to latest.log every few seconds while you play: frame rate, frame time and where it goes, on the CPU and on the GPU (timed on one frame in eight, so the frame rate stays the normal one). Off writes nothing. -* **Run benchmark**: Takes about 145 seconds, in a world: Etna tries its settings one at a time, writes what each one costs to latest.log, then puts everything back as it was. Stand still and don't move the mouse: a step disturbed by a camera move or a stutter is measured again, which can add up to about 20 seconds. Turn any frame-rate limiter mod and VSync off first. +* **Run benchmark**: Takes about 150 seconds, in a world: Etna tries its settings one at a time, writes what each one costs to latest.log, then puts everything back as it was. Stand still and don't move the mouse: a step disturbed by a camera move or a stutter is measured again, which can add up to about 20 seconds. Turn any frame-rate limiter mod and VSync off first. ## Mood diff --git a/src/main/java/it/cosimo/etna/EtnaBenchmark.java b/src/main/java/it/cosimo/etna/EtnaBenchmark.java index 55c213e..4d862b7 100644 --- a/src/main/java/it/cosimo/etna/EtnaBenchmark.java +++ b/src/main/java/it/cosimo/etna/EtnaBenchmark.java @@ -30,7 +30,7 @@ * A step whose camera moves or turns, or that hits a frame over 50 ms (a pipeline compile, chunks * loading, a garbage collection), starts measuring again half a second later, up to three tries; only a * few of those in a run, so it stays within about two minutes. The table marks the steps that stayed - * disturbed. Twenty-two steps take about 145 seconds. The run moves on from the start of each frame; a + * disturbed. Twenty-three steps take about 150 seconds. The run moves on from the start of each frame; a * client-tick listener only watches Etna's key. * * The sun holds still for the run, where Etna and the sky read it: in the two minutes a run takes it diff --git a/src/main/resources/assets/etna/lang/en_us.json b/src/main/resources/assets/etna/lang/en_us.json index efd44d5..6ef24cf 100644 --- a/src/main/resources/assets/etna/lang/en_us.json +++ b/src/main/resources/assets/etna/lang/en_us.json @@ -122,7 +122,7 @@ "etna.options.profiling.desc": "Summary writes a line to latest.log every few seconds while you play: frame rate, frame time and where it goes, on the CPU and on the GPU (timed on one frame in eight, so the frame rate stays the normal one). Off writes nothing.", "etna.options.profiling.summary": "Summary", "etna.options.benchmark": "Run benchmark", - "etna.options.benchmark.desc": "Takes about 145 seconds, in a world: Etna tries its settings one at a time, writes what each one costs to latest.log, then puts everything back as it was. Stand still and don't move the mouse: a step disturbed by a camera move or a stutter is measured again, which can add up to about 20 seconds. Turn any frame-rate limiter mod and VSync off first.", + "etna.options.benchmark.desc": "Takes about 150 seconds, in a world: Etna tries its settings one at a time, writes what each one costs to latest.log, then puts everything back as it was. Stand still and don't move the mouse: a step disturbed by a camera move or a stutter is measured again, which can add up to about 20 seconds. Turn any frame-rate limiter mod and VSync off first.", "etna.benchmark.start": "Etna benchmark: stand still and don't move the mouse for about %s s; turn the frame-rate limiter mod off first.", "etna.benchmark.done": "Etna benchmark finished: the results are in latest.log.", "etna.benchmark.unsteady": "Etna benchmark finished, but the scene changed during the run: the results in latest.log are rough. Run it again, standing still, once the chunks around have loaded.", diff --git a/src/main/resources/assets/etna/lang/it_it.json b/src/main/resources/assets/etna/lang/it_it.json index df8f1a5..98161cf 100644 --- a/src/main/resources/assets/etna/lang/it_it.json +++ b/src/main/resources/assets/etna/lang/it_it.json @@ -122,7 +122,7 @@ "etna.options.profiling.desc": "Con Riepilogo, mentre giochi, ogni pochi secondi scrive una riga in latest.log: frame al secondo, durata dei frame e dove va il tempo, sulla CPU e sulla GPU (misurata su un frame ogni otto, così gli fps restano quelli normali). Con No non scrive nulla.", "etna.options.profiling.summary": "Riepilogo", "etna.options.benchmark": "Avvia benchmark", - "etna.options.benchmark.desc": "Dura circa 145 secondi, in un mondo: Etna prova le sue impostazioni una alla volta, scrive in latest.log quanto costa ognuna e poi rimette tutto com'era. Stai fermo e non muovere il mouse: un passo disturbato da un movimento della visuale o da uno scatto viene misurato di nuovo, il che può aggiungere fino a una ventina di secondi. Prima disattiva la mod che limita i frame e il Sincronismo verticale.", + "etna.options.benchmark.desc": "Dura circa 150 secondi, in un mondo: Etna prova le sue impostazioni una alla volta, scrive in latest.log quanto costa ognuna e poi rimette tutto com'era. Stai fermo e non muovere il mouse: un passo disturbato da un movimento della visuale o da uno scatto viene misurato di nuovo, il che può aggiungere fino a una ventina di secondi. Prima disattiva la mod che limita i frame e il Sincronismo verticale.", "etna.benchmark.start": "Benchmark di Etna: stai fermo e non muovere il mouse per circa %s s; prima disattiva la mod che limita i frame al secondo.", "etna.benchmark.done": "Benchmark di Etna finito: i risultati sono in latest.log.", "etna.benchmark.unsteady": "Benchmark di Etna finito, ma la scena è cambiata durante la prova: i risultati in latest.log sono approssimativi. Rifallo stando fermo, quando i chunk intorno sono caricati.", From 10f13373d07e376b29ddaf5eeb6df6d820cd8be2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 03:02:04 +0000 Subject: [PATCH 16/24] Etna 0.8.1: review fixes for the water shadow and reflection history Far water seen at a grazing angle changes its distance from the camera by several blocks a pixel, more than the history check's 0.3 + 3 % allowed, so the reflections pass threw its history away exactly where the shore's reflection flickers most (from about 60 blocks out, sooner from a boat). The check now also allows four pixels' change in distance on the water (the history texels lie up to two pixels away, plus last frame's jitter), capped at a quarter of the distance. The water body's four shadow points go down along the bent view, so where the bottom rises ahead of it (a beach stepping up out of a lake) the deeper ones ended up inside the sand and read the bottom above them as a shadow: a darker band in front of the steps. Each point is now looked up where its way to the sun crosses the water's surface (the same texels of the map), so only what stands above the water shades it. The header no longer claims a still scene traces the same rays every frame: each ray starts from the point the jittered pixel shows. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Xzo7zbTpyU7a3y3XQNbw1n --- .../etna/shaders/include/terrain_water.glsl | 11 ++++++--- .../assets/etna/shaders/post/water_ssr.fsh | 23 ++++++++++++++----- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/main/resources/assets/etna/shaders/include/terrain_water.glsl b/src/main/resources/assets/etna/shaders/include/terrain_water.glsl index 3e52c1f..cf886e9 100644 --- a/src/main/resources/assets/etna/shaders/include/terrain_water.glsl +++ b/src/main/resources/assets/etna/shaders/include/terrain_water.glsl @@ -588,8 +588,11 @@ float waterDepthBelow(out float trust) { // the surface took the sharp shadow of its own point, a second copy of every shadow next to the one on // the bottom (shifted from it by the depth, the more the lower the sun), and the two crossed each other out // in strange shapes. Four points down the view's way into the water, each at an equal share of the light -// scattered back above the bottom, each the bilinear blend of four texels (shadowSoftAt). Seen from -// below, or past the map, the surface's own shadow as before. +// scattered back above the bottom, each the bilinear blend of four texels (shadowSoftAt). A point counts +// as shaded only by what stands above the water's surface on its way to the sun: it is looked up where +// that way crosses the surface (the same texels of the map, a shallower depth), so a point that ends up +// inside a bottom rising ahead of the view (a beach stepping up out of a lake) doesn't take the bottom over +// it for a shadow. Seen from below, or past the map, the surface's own shadow as before. float waterBodyShadow(vec3 normal) { if (normal.y < 0.8 || cameraInAir() < 0.5) { return shadowVisibility(normal, false); @@ -606,9 +609,11 @@ float waterBodyShadow(vec3 normal) { float perDepth = waterExtinction(EtnaWaterLook.x).g * (1.0 / max(ETNA_LIGHT_DIR.y, 0.1) + 1.0 / cosDown); float total = 1.0 - exp(-perDepth * below); float lit = 0.0; + // Along the light to the surface's plane from z blocks under it: same map texels, the depth there. + vec3 up = ETNA_LIGHT_DIR / max(ETNA_LIGHT_DIR.y, 0.05); for (int i = 0; i < 4; i++) { float z = -log(1.0 - (float(i) + 0.5) * 0.25 * total) / perDepth; - lit += shadowSoftAt(etnaViewPos + down * (z / cosDown)); + lit += shadowSoftAt(etnaViewPos + down * (z / cosDown) + up * z); } lit *= 0.25; return trust < 1.0 ? mix(shadowVisibility(normal, false), lit, trust) : lit; diff --git a/src/main/resources/assets/etna/shaders/post/water_ssr.fsh b/src/main/resources/assets/etna/shaders/post/water_ssr.fsh index 2a542fc..829d597 100644 --- a/src/main/resources/assets/etna/shaders/post/water_ssr.fsh +++ b/src/main/resources/assets/etna/shaders/post/water_ssr.fsh @@ -6,12 +6,13 @@ // 0.8.0 traced a different pixel of the block each frame along steps shifted by noise, for the // anti-aliasing to average, but where only some rays met a thin edge (leaves, a fence, a block's corner) // the anti-aliasing kept them as specks, and the reflections read as dots. The same rays every frame give -// a steady image that only moves with the camera and the waves. The rays are traced where the pixel's -// centre would be without the anti-aliasing's jitter, and every read of the depth and the picture is moved -// by the jitter to where the picture holds that point, so a still scene gives the same rays every frame. +// a steady image that only moves with the camera and the waves. Each ray starts from the point of the water +// the jittered pixel shows (up to half a pixel from its centre, a different way each frame), worked out +// without the jitter, and every read of the depth and the picture is moved by the jitter to where the +// picture holds the point it wants, so the rays stay true to the scene whatever the jitter. // // Even so, where a ray only just meets or misses an edge it can go either way from one frame to the next -// (the jittered picture's edges move by up to half a pixel, the waves move on), and a whole 2x2 block, then +// (its start moves by up to half a pixel, the waves move on), and a whole 2x2 block, then // spread over its neighbours by the world pass, changes at once: too smooth for the anti-aliasing, which // keeps the old frames only where they fit the new one's neighbouring pixels, to tell from a real change, // so the reflection flickered. With anti-aliasing on, each texel is now blended with the last frames' own @@ -157,6 +158,11 @@ void nothing() { fragColor = vec4(0.0); } +// How far, in blocks, the distance from the camera changes across the few pixels between a point and the +// history texels round it (main sets it from the pixel's size on the water): on water seen at a grazing +// angle far away, several blocks a pixel. +float historySlack = 0.0; + // The last frames' change for the water point at camera-relative `position`: rgb, and in a how much of it can // be gone by (0 none: no history, the point off last frame's screen, or no water at its distance there). vec4 lastChange(vec3 position) { @@ -174,7 +180,7 @@ vec4 lastChange(vec3 position) { return vec4(0.0); } // Texel c held pixel 2c: blend the four round the point, each only if it held water at the point's - // distance from last frame's camera. + // distance from last frame's camera, give or take how much that distance changes across them. vec2 c = (uv * DepthSize - 0.5) * 0.5; ivec2 base = ivec2(floor(c)); vec2 f = c - vec2(base); @@ -186,7 +192,7 @@ vec4 lastChange(vec3 position) { ivec2 o = ivec2(i & 1, i >> 1); vec4 texel = texelFetch(SsrHistorySampler, clamp(base + o, ivec2(0), last), 0); float w = (o.x == 1 ? f.x : 1.0 - f.x) * (o.y == 1 ? f.y : 1.0 - f.y); - if (texel.a > 0.0 && abs(texel.a - dist) < 0.3 + 0.03 * dist) { + if (texel.a > 0.0 && abs(texel.a - dist) < 0.3 + 0.03 * dist + historySlack) { sum += w * texel.rgb; weights += w; } @@ -255,6 +261,11 @@ void main() { return; } float footprint = max(length(dx), length(dy)); + // A history texel's point lies up to a texel (two pixels) from this one, and last frame's jitter put it + // up to half a pixel further: four pixels' change in distance covers both. Capped, so a pixel at the + // water's edge whose neighbour lies on something else can't let in any history at all. + vec3 toPoint = normalize(position); + historySlack = min(4.0 * max(abs(dot(dx, toPoint)), abs(dot(dy, toPoint))), 0.25 * length(position)); // The swell where this pixel lies, as the water shader drew it (shadeWater): the same waves, as far as // the pixel resolves them. From c5e5c1542afa735583f1191bfc595827ab1a07cf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 03:11:25 +0000 Subject: [PATCH 17/24] Entity lighting: compile the copies ahead, no allocation per draw, totem keeps vanilla light From an independent review of the entity lighting: - The copies are built and compiled two a frame as the level starts, not at their first draw, which stalled the game when a new mob, an enchanted item or the hand first came into view. - The draw path is a plain map lookup: no lambda per draw, the compile is checked once per copy (again after a resource reload), and the shadow block's slice is made once per buffer. - The hand phase ends right after the hand, so the totem screen effect keeps vanilla's light (its normals aren't world-oriented). - The special glint copies get names of their own. - A hand copy that fails no longer disables the level's copy, and back. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01F7MajtknSApr1T68EGCPtx --- docs/settings.md | 2 +- .../java/it/cosimo/etna/EtnaEntities.java | 102 ++++++++++++++---- src/main/java/it/cosimo/etna/EtnaShadows.java | 15 ++- .../cosimo/etna/mixin/GameRendererMixin.java | 10 ++ 4 files changed, 109 insertions(+), 20 deletions(-) diff --git a/docs/settings.md b/docs/settings.md index 9a4976e..c2bc5f4 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -74,7 +74,7 @@ Sunlight, moonlight, shadows, the faint light in caves, and the light on mobs an * **Shadow Darkness**: How dark the shade is next to sunlight. * **Sunlight**: Strength of direct sunlight. * **Cave Light**: Caves and dark rooms are never pitch black: a faint, cool light shows their shape. -* **Entity Lighting** (default On): Mobs, chests, armour, dropped and held items and your hand take the same sunlight, blue shade, shadows and warm torch light as the land around them. Off keeps Minecraft's own flat light on them. Your hand takes the shade of whatever stands over you. Not part of the Look or the Quality. Items and mobs in the inventory and other menus always keep Minecraft's light; with Improved Transparency on, translucent mobs (and a few translucent items) keep it in the world too. +* **Entity Lighting** (default On): Mobs, chests, armour, dropped and held items and your hand take the same sunlight, blue shade, shadows and warm torch light as the land around them. Off keeps Minecraft's own flat light on them. Your hand takes the shade of whatever stands over you. Not part of the Look or the Quality. Items and mobs in the inventory and other menus always keep Minecraft's light; with Improved Transparency on, other players, translucent mobs and a few translucent items keep it in the world too. ## Surfaces diff --git a/src/main/java/it/cosimo/etna/EtnaEntities.java b/src/main/java/it/cosimo/etna/EtnaEntities.java index 18fe90e..493fb94 100644 --- a/src/main/java/it/cosimo/etna/EtnaEntities.java +++ b/src/main/java/it/cosimo/etna/EtnaEntities.java @@ -29,7 +29,7 @@ * Only draws Etna should light are swapped: while Etna's terrain block is ready this frame (EtnaShadows.lightBlock, * so Etna is on and in a world), Entity Lighting is on, and the draw is part of the level (from the shadow pass * hook in LevelRendererMixin until the hand) or of the first-person hand (GameRendererMixin, from right before the - * hand is drawn until the end of renderLevel), never the menus: the inventory's items and mobs, the item atlas and + * hand is drawn until it is drawn), never the menus or the screen effects: the inventory's items and mobs, the item atlas and * picture-in-picture use their own projection and lights, and draw after the level. The hand's copies define * ETNA_HAND: it is drawn close to the eye with its own projection, so it is lit as if it stood at the camera. * Only the pipelines in pipelines() are swapped, by identity (the glint variants share their locations with the @@ -39,14 +39,17 @@ * * Both injections are optional (require = 0): if the game changes under them, entities simply keep vanilla's light. * Nothing is swapped until the binding injection has run once, so a copy is never drawn without its block; if - * neither ever runs while Etna would light entities, that is logged once. A copy that does not compile leaves its - * pipeline vanilla's for the session (until a resource reload), an error leaves all of them vanilla's. + * neither ever runs while Etna would light entities, that is logged once. The copies are compiled a couple a frame + * as the level starts (warmUp), not at their first draw. A copy that does not compile leaves its pipeline + * vanilla's there (level or hand) until a resource reload, an error leaves all of them vanilla's. */ public final class EtnaEntities { private static final Identifier ENTITY_SHADER = Identifier.fromNamespaceAndPath(EtnaClient.MOD_ID, "core/entity"); private static final Identifier ITEM_SHADER = Identifier.fromNamespaceAndPath(EtnaClient.MOD_ID, "core/item"); /** Frames of the level drawn with Entity Lighting active before a hook that never ran is logged. */ private static final int HOOK_GRACE_FRAMES = 600; + /** Copies built and compiled ahead of their first draw, per frame of the level (warmUp). */ + private static final int WARM_UP_PER_FRAME = 2; /** Which part of the frame is being drawn, for which copies, if any, to draw with. */ private enum Phase { NONE, WORLD, HAND } @@ -54,10 +57,16 @@ private enum Phase { NONE, WORLD, HAND } private static Phase phase = Phase.NONE; /** The vanilla pipelines Etna lights, each with the shader of its copy; made on first use (pipelines). */ private static @Nullable Map lit; + /** The same pipelines in a fixed order, for warmUp. */ + private static RenderPipeline @Nullable [] litOrder; + /** Copies that compiled, by the vanilla pipeline they stand in for: until the next resource reload. */ private static final Map worldCopies = new IdentityHashMap<>(); private static final Map handCopies = new IdentityHashMap<>(); - /** Vanilla pipelines whose copy did not compile: drawn as vanilla's until the next resource reload. */ - private static final Set failed = new ReferenceOpenHashSet<>(); + /** Vanilla pipelines whose copy did not compile, for the level and for the hand: drawn as vanilla's until the next resource reload. */ + private static final Set failedWorld = new ReferenceOpenHashSet<>(); + private static final Set failedHand = new ReferenceOpenHashSet<>(); + /** How many copies warmUp has prepared since the last resource reload, world and hand ones in turn. */ + private static int warmed; /** Set after an error: vanilla's light on every entity for the rest of the session. */ private static boolean broken; /** Whether each injection has run at least once: the pipeline swap, and the binding after it. */ @@ -77,6 +86,7 @@ private EtnaEntities() { */ public static void beginWorld() { phase = Phase.WORLD; + warmUp(); if (!hooksWarned && !(swapHookSeen && bindHookSeen) && wanted() && ++framesWithoutHooks > HOOK_GRACE_FRAMES) { hooksWarned = true; EtnaClient.LOGGER.warn("Etna: the entity lighting hook did not run (pipeline swap {}, binding {}); entities, items and the hand" @@ -89,7 +99,10 @@ public static void beginHand() { phase = Phase.HAND; } - /** GameRendererMixin, at the end of renderLevel and at the start of every frame: the menus and anything else are vanilla's. */ + /** + * GameRendererMixin, at the end of renderLevel, at the start of every frame and right after the hand is drawn + * (before the screen effects, whose totem is lit as vanilla's): the menus and anything else are vanilla's. + */ public static void endLevel() { phase = Phase.NONE; } @@ -106,27 +119,69 @@ private static boolean wanted() { public static RenderPipeline pipelineFor(RenderPipeline vanilla) { swapHookSeen = true; swapped = false; - if (phase == Phase.NONE || !bindHookSeen || !wanted() || failed.contains(vanilla)) { + if (phase == Phase.NONE || !bindHookSeen || !wanted()) { return vanilla; } + boolean hand = phase == Phase.HAND; + RenderPipeline copy = (hand ? handCopies : worldCopies).get(vanilla); + if (copy == null) { + copy = prepare(vanilla, hand); + if (copy == null) { + return vanilla; + } + } + swapped = true; + return copy; + } + + /** + * The copy of {@code vanilla} for the level or the hand, built and compiled now and kept until the next resource + * reload; null if {@code vanilla} is not one Etna lights, its copy did not compile, or an error left entities + * vanilla's. The compile is checked once here, not at every draw. + */ + private static @Nullable RenderPipeline prepare(RenderPipeline vanilla, boolean hand) { + Set failed = hand ? failedHand : failedWorld; + if (broken || failed.contains(vanilla)) { + return null; + } try { Identifier shader = pipelines().get(vanilla); if (shader == null) { - return vanilla; + return null; } - boolean hand = phase == Phase.HAND; - RenderPipeline copy = (hand ? handCopies : worldCopies).computeIfAbsent(vanilla, p -> makeCopy(p, shader, hand)); + RenderPipeline copy = makeCopy(vanilla, shader, hand); if (RenderSystem.getCompiledPipelineNullable(copy) == null) { failed.add(vanilla); EtnaClient.LOGGER.error("Etna: {} did not compile, those entities keep vanilla's light", copy.getLocation()); - return vanilla; + return null; } - swapped = true; + (hand ? handCopies : worldCopies).put(vanilla, copy); return copy; } catch (RuntimeException e) { broken = true; EtnaClient.LOGGER.error("Etna: lighting entities failed, entities, items and the hand keep vanilla's light for this session", e); - return vanilla; + return null; + } + } + + /** + * At the start of the level, a couple of copies a frame are compiled before anything draws with them, all of + * them over the first frames in a world and after a resource reload. Compiled at their first draw instead, each + * would stall the game when a new kind of mob, an enchanted item or the hand first came into view, which on some + * drivers takes a good part of a second. + */ + private static void warmUp() { + if (!wanted()) { + return; + } + pipelines(); + RenderPipeline[] order = litOrder; + for (int i = 0; i < WARM_UP_PER_FRAME && order != null && warmed < 2 * order.length; i++, warmed++) { + RenderPipeline vanilla = order[warmed >> 1]; + boolean hand = (warmed & 1) == 1; + if (!(hand ? handCopies : worldCopies).containsKey(vanilla)) { + prepare(vanilla, hand); + } } } @@ -185,6 +240,7 @@ private static Map pipelines() { }) { table.put(p, ITEM_SHADER); } + litOrder = table.keySet().toArray(new RenderPipeline[0]); lit = table; } return table; @@ -206,11 +262,14 @@ private static RenderPipeline makeCopy(RenderPipeline vanilla, Identifier shader if (hand) { builder.withShaderDefine("ETNA_HAND"); } - // The glint variants share their locations with the plain pipelines: their copies get names of their own. + // The glint variants share their locations with the plain pipelines (or the special glint with the plain + // glint): their copies get names of their own. String path = vanilla.getLocation().getPath(); var flags = vanilla.getShaderDefines().flags(); - if (!path.contains("glint")) { - path += flags.contains("GLINT_SPECIAL") ? "_glint_special" : flags.contains("GLINT") ? "_glint" : ""; + if (flags.contains("GLINT_SPECIAL")) { + path += path.contains("glint") ? "_special" : "_glint_special"; + } else if (flags.contains("GLINT") && !path.contains("glint")) { + path += "_glint"; } return builder .withBindGroupLayout(EtnaShadows.LAYOUT) @@ -218,8 +277,15 @@ private static RenderPipeline makeCopy(RenderPipeline vanilla, Identifier shader .build(); } - /** EtnaFrame.resourcesReloaded: the shaders were recompiled, so a copy that failed gets another try. */ + /** + * EtnaFrame.resourcesReloaded: the shaders were recompiled, so every copy is built and checked again, and one + * that failed gets another try. + */ static void resourcesReloaded() { - failed.clear(); + worldCopies.clear(); + handCopies.clear(); + failedWorld.clear(); + failedHand.clear(); + warmed = 0; } } diff --git a/src/main/java/it/cosimo/etna/EtnaShadows.java b/src/main/java/it/cosimo/etna/EtnaShadows.java index aab7a27..7acc019 100644 --- a/src/main/java/it/cosimo/etna/EtnaShadows.java +++ b/src/main/java/it/cosimo/etna/EtnaShadows.java @@ -225,6 +225,9 @@ public boolean bilinear() { private static final @Nullable GpuTexture[] textures = new GpuTexture[2]; private static final @Nullable GpuTextureView[] views = new GpuTextureView[2]; private static @Nullable GpuBuffer ubo; + /** ubo.slice(), kept for the buffer it was made of (uboSlice). */ + private static @Nullable GpuBufferSlice uboSlice; + private static @Nullable GpuBuffer uboSliceOf; private static @Nullable ProjectionMatrixBuffer projectionBuffer; private static int textureSize; @@ -1270,11 +1273,21 @@ static void bindLight(RenderPass pass) { GpuSampler nearest = RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST); pass.setUniform("EtnaShadowSampler", views[primary], nearest); pass.setUniform("EtnaShadowSampler2", views[1 - primary], nearest); - pass.setUniform("EtnaShadow", ubo.slice()); + pass.setUniform("EtnaShadow", uboSlice()); // Bilinear, and wrapping round as the clouds repeat. pass.setUniform("EtnaCloudSampler", EtnaClouds.texture(), RenderSystem.getSamplerCache().getRepeat(FilterMode.LINEAR)); } + /** The whole block as one slice, made once per buffer: bindLight runs for every lit entity draw. */ + private static GpuBufferSlice uboSlice() { + GpuBuffer buffer = ubo; + if (uboSliceOf != buffer || uboSlice == null) { + uboSlice = buffer.slice(); + uboSliceOf = buffer; + } + return uboSlice; + } + public static void endTerrain() { bindingMainTerrain = false; } diff --git a/src/main/java/it/cosimo/etna/mixin/GameRendererMixin.java b/src/main/java/it/cosimo/etna/mixin/GameRendererMixin.java index f4eea20..7896ec0 100644 --- a/src/main/java/it/cosimo/etna/mixin/GameRendererMixin.java +++ b/src/main/java/it/cosimo/etna/mixin/GameRendererMixin.java @@ -132,6 +132,16 @@ public abstract class GameRendererMixin { EtnaEntities.beginHand(); } + @Inject( + method = "render3dHud", + at = @At(value = "INVOKE", target = "Lnet/minecraft/client/renderer/GameRenderer;renderItemInHand", shift = At.Shift.AFTER), + require = 0 + ) + private void etna$handDrawn(CallbackInfo ci) { + // The screen effects after the hand (the totem's item) keep vanilla's light, as in the menus (EtnaEntities). + EtnaEntities.endLevel(); + } + @Redirect( method = "render", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/renderer/LevelRenderer;blitEntityOutline()V") From b14c10689c7d105b67fc66eb1d3889306385f1e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 03:47:50 +0000 Subject: [PATCH 18/24] Auto Resolution: hold a frame rate by steering the world's resolution A new General-page setting (off, 60, 90, 120, 144, 165 fps). EtnaAutoScale picks the World Resolution from a few fixed steps (100% down to 50%) by the GPU's own time per frame, from the GPU timer on one frame in eight, so VSync and frame limiters don't hide the room there is: - two timed frames over the budget step down, one far over it two steps; - a step up only when every timed frame of the last two seconds would still fit with headroom at the bigger size, counting the whole frame as growing with the pixels, so it doesn't bounce straight back; - frames drawn before a step aren't counted after it. It never goes below World Resolution (70% when that is at 100%), is off during the benchmark, and keeps the setting without GPU timestamps. The performance log shows the resolution it picked. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01F7MajtknSApr1T68EGCPtx --- CHANGELOG.md | 1 + docs/settings.md | 1 + .../java/it/cosimo/etna/EtnaAutoScale.java | 135 ++++++++++++++++++ src/main/java/it/cosimo/etna/EtnaConfig.java | 14 +- .../java/it/cosimo/etna/EtnaGpuTimer.java | 3 +- .../java/it/cosimo/etna/EtnaProfiler.java | 24 +++- src/main/java/it/cosimo/etna/EtnaScale.java | 14 +- .../cosimo/etna/gui/EtnaSettingsScreen.java | 17 +++ .../resources/assets/etna/lang/en_us.json | 3 + .../resources/assets/etna/lang/it_it.json | 3 + 10 files changed, 207 insertions(+), 8 deletions(-) create mode 100644 src/main/java/it/cosimo/etna/EtnaAutoScale.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bce68f..412f0dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Faster with World Resolution, and Etna's own anti-aliasing. * **Mobs, chests, armour, items and your hand in Etna's light.** They kept Minecraft's flat two-light shading while the land around them had Etna's golden sun, blue shade, shadows and warm torches, so they looked pasted in. Now they are lit the way the land is: the sun and the sky's colour on each face, the shadow map and the clouds' shadows, the warm torch light, the cave light and the distance haze, so a mob under a tree stands in its shade and a white block entity is as bright as the white ground it stands on. Your hand takes the shade of whatever stands over you. Enchantment glints, the hurt flash and the fog stay as they were, and the inventory and other menus keep Minecraft's own light. Entity Lighting (new, Lighting & Shadows page) turns it off; the benchmark has a step for it. With Improved Transparency on, translucent mobs and items keep Minecraft's light for now. * **World Resolution (new, General page, 50 to 100%).** Draws the world at fewer pixels and builds the full-size picture back up in Etna's anti-aliasing, which moves the camera by a different fraction of a pixel every frame and puts the frames together at full size. Most of Etna's cost is per pixel, so at 75% the world shades a little over half the pixels. Textures keep their full-size detail, and the hand, the menus and the HUD stay sharp at full size. Needs anti-aliasing, which every Quality but Low has. The benchmark has a step for it. +* **Auto Resolution (new, General page, off by default).** Pick a frame rate (60, 90, 120, 144 or 165 fps) and Etna lowers the world's resolution by itself where a scene is too heavy to hold it (a dense forest, a wide view, a storm), then raises it back once there's room. It steers by how long the graphics card takes per frame, so VSync doesn't fool it, and it never goes below World Resolution (70% when that is at 100%). It drops quickly and climbs back slowly, so it doesn't swing up and down. The performance log shows the resolution it picked. * **New anti-aliasing, Etna's own.** Steadier and sharper than 0.8's: in Etna's test bench about a third less flicker and error, edges closer to a supersampled picture, and less trailing behind moving things. Glints and sparkles brighter than white no longer flash through it. * **No code from other shader packs any more.** The anti-aliasing and the light shafts' pixel order were the last parts from Photon Shaders; with 0.8.1's own shadow warp and water Fresnel, Etna is now entirely its own code (and Minecraft's shaders it builds on), so it can go on Modrinth and CurseForge. `LICENSE-PHOTON.txt` is gone. diff --git a/docs/settings.md b/docs/settings.md index c2bc5f4..53d58e3 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -23,6 +23,7 @@ Start here. Quality sets how much work Etna does, Look sets the colours and mood * **Look**: Natural keeps Minecraft's own colours under real light. Soft is gentler and hazier, Vivid more saturated, Cinematic warmer with more contrast and glow. * **Visibility**: How well you see in the dark, without making bright places brighter: caves get more of their faint light (with Cave Light on), sun and moon shadows get lighter, your eyes open up further in dark places (with Eye Adaptation on) and the darkest tones are lifted. Bright skies, snow and sunlit ground stay as they are. 0% (Off) is Etna 0.7.0's darkness. It stays where you put it when you change the Look. * **World Resolution**: How many pixels the world is drawn at, as a share of the screen's width and height. Below 100% Etna draws the world smaller and its anti-aliasing builds the full picture back up over a few frames, the way DLSS and FSR do: much faster, a little softer, mostly in motion. 75% draws about half the pixels. The hand, the HUD and menus stay sharp. Needs anti-aliasing, which every Quality but Low has. +* **Auto Resolution** (default Off; 60, 90, 120, 144 or 165 fps): Holds that frame rate by lowering the world's resolution by itself in heavy scenes and raising it back when there's room. It steers by how long the graphics card takes per frame (timed on one frame in eight), so VSync doesn't fool it. It never goes below World Resolution, or 70% when that is at 100%. Needs anti-aliasing and a driver with GPU timestamps; off during the benchmark. With the performance log on, each line shows the resolution it picked. * **Resolution** *(macOS only)*: Half draws a quarter of the pixels and lets macOS scale the picture up: much faster, but blurry. Applies after restarting the game, and only when Etna is on as the game starts. * **Performance log**: Summary writes a line to latest.log every few seconds while you play: frame rate, frame time and where it goes, on the CPU and on the GPU (timed on one frame in eight, so the frame rate stays the normal one). Off writes nothing. * **Run benchmark**: Takes about 150 seconds, in a world: Etna tries its settings one at a time, writes what each one costs to latest.log, then puts everything back as it was. Stand still and don't move the mouse: a step disturbed by a camera move or a stutter is measured again, which can add up to about 20 seconds. Turn any frame-rate limiter mod and VSync off first. diff --git a/src/main/java/it/cosimo/etna/EtnaAutoScale.java b/src/main/java/it/cosimo/etna/EtnaAutoScale.java new file mode 100644 index 0000000..b639d82 --- /dev/null +++ b/src/main/java/it/cosimo/etna/EtnaAutoScale.java @@ -0,0 +1,135 @@ +package it.cosimo.etna; + +/** + * Auto Resolution (0.9.0): picks the World Resolution (EtnaScale) frame by frame to hold a frame rate, the way + * games' dynamic resolution does (the technique as Unreal Engine describes it: a GPU time budget, headroom, a + * quick drop when over budget and a slow climb back). Etna is GPU-bound on most machines, so the GPU's own time per + * frame is what is steered, not the frame interval, which VSync and frame limiters would hold at their cap. + * + * The GPU time comes from EtnaGpuTimer, which times one frame in eight while Auto Resolution is on (as for the + * performance log). The scale moves in a few fixed steps, so the targets the world is drawn into change seldom + * and every pass still covers its whole target: + * - over the budget on two timed frames in a row, one step down; far over it on one, two steps down at once; + * - one step up only when every timed frame of the last two seconds would still have been well inside the budget + * at the bigger size, counting all of the frame as growing with the pixels (it doesn't: the shadow pass, the + * hand and the HUD stay as they are), so a step up does not come straight back down. + * Frames drawn before a step are not counted after it. It never goes below the World Resolution setting (70% when + * that is at 100%), never runs during the benchmark, and stays off without GPU timestamps (the frame interval + * alone can't tell room to spare from a VSync cap). Everything here runs on the render thread. + */ +public final class EtnaAutoScale { + /** The steps, largest first: about 0.85x the pixels from one to the next. */ + private static final float[] STEPS = {1.0F, 0.92F, 0.85F, 0.77F, 0.70F, 0.63F, 0.56F, 0.50F}; + /** The lowest step when World Resolution is at 100%. */ + private static final float DEFAULT_FLOOR = 0.70F; + /** A timed frame over the budget times this drops two steps at once. */ + private static final double PANIC = 1.3; + /** A step up must keep every recent timed frame under the budget times this, at the bigger size. */ + private static final double HEADROOM = 0.88; + /** How long every timed frame must have had room before a step up, and how long after any change. */ + private static final long UP_AFTER_NS = 2_000_000_000L; + /** How soon after a change another step down may come: a few timed frames at the new size. */ + private static final long DOWN_AFTER_NS = 300_000_000L; + + private static int step; + private static int overBudget; + /** The first frame drawn at the current step: timed frames from before it are not counted. */ + private static long firstFrame; + private static long changedAt; + /** Since when every timed frame would have fitted one step up. */ + private static long roomSince = -1L; + private static long frame; + + private EtnaAutoScale() { + } + + /** EtnaScale.wanted: this frame's scale, {@code fixed} (the setting) unless Auto Resolution steers it. */ + static float scale(float fixed) { + if (!active()) { + step = 0; + overBudget = 0; + roomSince = -1L; + return fixed; + } + return Math.max(STEPS[step], floor(fixed)); + } + + /** Whether Auto Resolution steers the scale now. */ + private static boolean active() { + return EtnaConfig.autoResolutionFps > 0 && !EtnaBenchmark.running() && EtnaGpuTimer.available(); + } + + /** Whether the GPU timer should time frames for Auto Resolution (EtnaProfiler, while the log is off). */ + static boolean wantsTiming() { + return active() && EtnaScale.couldScale(); + } + + /** EtnaProfiler, at the start of every frame, before the timed frames that came back: the frame number they carry. */ + static void frameStarted(long number) { + frame = number; + } + + private static float floor(float fixed) { + return fixed < 1.0F ? fixed : DEFAULT_FLOOR; + } + + private static int lowestStep(float floor) { + int lowest = 0; + while (lowest + 1 < STEPS.length && STEPS[lowest + 1] >= floor - 1e-4F) { + lowest++; + } + return lowest; + } + + /** EtnaGpuTimer: a timed frame came back, {@code frameNumber} as EtnaProfiler numbered it. */ + static void gpuFrame(long frameNumber, long totalNs) { + if (!active() || frameNumber < firstFrame) { + return; + } + long now = System.nanoTime(); + double budget = 1.0e9 / EtnaConfig.autoResolutionFps; + int lowest = lowestStep(floor(Math.clamp(EtnaConfig.renderScale, EtnaConfig.RENDER_SCALE_MIN, 1.0F))); + if (step > lowest) { + // The floor went up in the settings. + change(lowest, now); + return; + } + if (totalNs > budget) { + overBudget++; + roomSince = -1L; + if (step < lowest && now - changedAt >= DOWN_AFTER_NS && (overBudget >= 2 || totalNs > budget * PANIC)) { + change(Math.min(step + (totalNs > budget * PANIC ? 2 : 1), lowest), now); + } + return; + } + overBudget = 0; + if (step == 0) { + return; + } + double grow = (double) STEPS[step - 1] / STEPS[step]; + if (totalNs * grow * grow > budget * HEADROOM) { + roomSince = -1L; + return; + } + if (roomSince < 0L) { + roomSince = now; + } + if (now - roomSince >= UP_AFTER_NS && now - changedAt >= UP_AFTER_NS) { + change(step - 1, now); + } + } + + private static void change(int to, long now) { + step = to; + overBudget = 0; + roomSince = -1L; + changedAt = now; + // Timed frames come back at the start of a frame, before its level is sized: this one is the first at the new step. + firstFrame = frame; + } + + /** For the log: the step Auto Resolution is at, or -1 while it doesn't steer. */ + static float current() { + return active() ? STEPS[step] : -1.0F; + } +} diff --git a/src/main/java/it/cosimo/etna/EtnaConfig.java b/src/main/java/it/cosimo/etna/EtnaConfig.java index f6ea045..f705181 100644 --- a/src/main/java/it/cosimo/etna/EtnaConfig.java +++ b/src/main/java/it/cosimo/etna/EtnaConfig.java @@ -223,6 +223,12 @@ public static void resetSurfaces() { */ public static float renderScale = 1.0F; public static final float RENDER_SCALE_MIN = 0.5F; + /** + * Auto Resolution (General page): the frame rate EtnaAutoScale holds by lowering the world's resolution, down to + * renderScale (70% when that is 1); 0 is off. One of AUTO_RESOLUTION_FPS. + */ + public static int autoResolutionFps = 0; + public static final int[] AUTO_RESOLUTION_FPS = {0, 60, 90, 120, 144, 165}; /** The performance log: a line in latest.log every few seconds while in a world. */ public static Profiling profiling = Profiling.OFF; /** Skip shadow casters that can't shadow anything on screen. Only in the file, to compare with and without. */ @@ -408,6 +414,10 @@ public static void load() { } halfResolution = bool(props, "halfResolution", false); renderScale = number(props, "renderScale", 1.0F, RENDER_SCALE_MIN, 1.0F); + autoResolutionFps = (int) number(props, "autoResolutionFps", 0, 0, 1000); + if (autoResolutionFps > 0 && autoResolutionFps < 30) { + autoResolutionFps = 30; + } sunPathTilt = number(props, "sunPathTilt", SUN_PATH_TILT_DEFAULT, 0.0F, 45.0F); visibility.set(number(props, "visibility", VISIBILITY_DEFAULT, visibility.min, visibility.max)); // 0.8.0 MOOD @@ -468,6 +478,7 @@ public static void save() { props.setProperty("toggleKey", Integer.toString(toggleKey)); props.setProperty("halfResolution", Boolean.toString(halfResolution)); props.setProperty("renderScale", Float.toString(renderScale)); + props.setProperty("autoResolutionFps", Integer.toString(autoResolutionFps)); props.setProperty("sunPathTilt", Float.toString(sunPathTilt)); props.setProperty("visibility", Float.toString(visibility.value)); // 0.8.0 MOOD @@ -514,7 +525,8 @@ public static void save() { + " | cinematic | custom (the look values are only read back with profile=custom; eyeAdaptation: 0 (off) .. 1)." + " toggleKey: key code, F7 by" + " default. halfResolution: macOS only, applies after a restart. renderScale: 0.5 .. 1, the share of the screen's" - + " width and height the world is drawn at (with anti-aliasing on; 1: full size). sunPathTilt: 0 to 45, how many degrees the" + + " width and height the world is drawn at (with anti-aliasing on; 1: full size). autoResolutionFps: 0 (off) or" + + " the frame rate to hold by lowering the world's resolution, down to renderScale (0.7 when renderScale is 1). sunPathTilt: 0 to 45, how many degrees the" + " path of the sun and the moon leans to the south (0 is vanilla's path). visibility: 0 .. 1, how much better caves," + " nights and shade read (0: as in 0.7.0), kept whatever the profile. profiling: off | summary (a performance line in" + " latest.log every few seconds). shadowCulling: true | false (false draws every shadow caster, to compare)." diff --git a/src/main/java/it/cosimo/etna/EtnaGpuTimer.java b/src/main/java/it/cosimo/etna/EtnaGpuTimer.java index bdb840c..5028d27 100644 --- a/src/main/java/it/cosimo/etna/EtnaGpuTimer.java +++ b/src/main/java/it/cosimo/etna/EtnaGpuTimer.java @@ -351,6 +351,7 @@ private static void book(Slot slot) { if (totalNs > TOO_LONG_NS) { return; } + EtnaAutoScale.gpuFrame(slot.frame, totalNs); passNs[0] = interval(Mark.SHADOW_START, Mark.SHADOW_END); // The level is everything from the frame's start up to the world pass, but for the shadow pass inside it. passNs[1] = interval(Mark.FRAME_START, Mark.SHADOW_START) + interval(Mark.SHADOW_END, Mark.LEVEL_END); @@ -373,7 +374,7 @@ private static void giveUp(String why) { unavailable = true; unavailableReason = why; recording = false; - EtnaClient.LOGGER.warn("[Etna] GPU timing is unavailable: {}. The performance log and the benchmark show CPU times only.", why); + EtnaClient.LOGGER.warn("[Etna] GPU timing is unavailable: {}. The performance log and the benchmark show CPU times only, and Auto Resolution keeps the World Resolution setting.", why); } private static void fail(RuntimeException e) { diff --git a/src/main/java/it/cosimo/etna/EtnaProfiler.java b/src/main/java/it/cosimo/etna/EtnaProfiler.java index 2a36eca..370cea7 100644 --- a/src/main/java/it/cosimo/etna/EtnaProfiler.java +++ b/src/main/java/it/cosimo/etna/EtnaProfiler.java @@ -119,8 +119,17 @@ public static void frameBegin() { logStarted = false; headerDue = true; } - // Gives the GPU timer's queries back once the frames still using them are done. - EtnaGpuTimer.collect(false); + // Auto Resolution steers by the GPU's time: one frame in EVERY is timed for it, as for the log. + boolean auto = EtnaAutoScale.wantsTiming(); + if (auto) { + frameNumber++; + EtnaAutoScale.frameStarted(frameNumber); + } + // Without it, gives the GPU timer's queries back once the frames still using them are done. + EtnaGpuTimer.collect(auto); + if (auto && frameNumber % EtnaGpuTimer.EVERY == 0) { + EtnaGpuTimer.beginFrame(frameNumber, Minecraft.getInstance().gameRenderer.gameRenderState().shouldRenderLevel); + } return; } try { @@ -138,6 +147,7 @@ public static void frameBegin() { } timing = true; frameNumber++; + EtnaAutoScale.frameStarted(frameNumber); startInterval(now); // GPU times of earlier frames that have come back, booked before the log or a step closes its window. EtnaGpuTimer.collect(true); @@ -360,6 +370,10 @@ private static void shadowPart(int part, long ns) { * benchmark step being measured; each keeps it only if the frame started inside its window. */ static void gpuFrame(long frame, long[] passNs, long totalNs) { + if (!timing) { + // Timed for Auto Resolution alone, with the log off. + return; + } log.addGpu(frame, passNs, totalNs); Stats bench = EtnaBenchmark.measuring(); if (bench != null) { @@ -485,7 +499,9 @@ static String settings() { String shadows = EtnaConfig.shadows ? "shadows " + EtnaConfig.shadowResolution + "/" + Math.round(EtnaConfig.shadowDistance) : "shadows off"; - return title(EtnaConfig.quality) + " " + shadows + ", AA " + (EtnaConfig.antiAliasing ? "on" : "off") + ", " + title(EtnaConfig.profile); + return title(EtnaConfig.quality) + " " + shadows + ", AA " + (EtnaConfig.antiAliasing ? "on" : "off") + ", " + title(EtnaConfig.profile) + + ", world " + Math.round(100.0F * EtnaScale.levelWidth() / EtnaScale.screenWidth()) + "%" + + (EtnaConfig.autoResolutionFps > 0 ? " (auto " + EtnaConfig.autoResolutionFps + " fps)" : ""); } /** Device, settings and anything else that changes what the numbers mean: printed before the first line. */ @@ -503,6 +519,8 @@ static String setup(Minecraft mc) { .append(", anti-aliasing ").append(EtnaConfig.antiAliasing ? "on" : "off") .append("), profile ").append(title(EtnaConfig.profile)) .append(", half resolution ").append(EtnaConfig.halfResolution ? "on" : "off") + .append(", world resolution ").append(Math.round(EtnaConfig.renderScale * 100.0F)).append('%') + .append(", auto resolution ").append(EtnaConfig.autoResolutionFps > 0 ? EtnaConfig.autoResolutionFps + " fps" : "off") .append(", shadow culling ").append(EtnaConfig.shadowCulling ? "on" : "off") .append(EtnaConfig.shadowGather ? "" : ", shadow gather off") .append(", render distance ").append(mc.options.getEffectiveRenderDistance()).append(" chunks") diff --git a/src/main/java/it/cosimo/etna/EtnaScale.java b/src/main/java/it/cosimo/etna/EtnaScale.java index 7f2db14..673e11c 100644 --- a/src/main/java/it/cosimo/etna/EtnaScale.java +++ b/src/main/java/it/cosimo/etna/EtnaScale.java @@ -67,12 +67,20 @@ public final class EtnaScale { private EtnaScale() { } - /** The scale the settings ask for, clamped; 1 when it can't apply (Etna or its anti-aliasing off). */ + /** + * The scale the settings ask for, clamped, or the one Auto Resolution picked (EtnaAutoScale); 1 when it can't + * apply (Etna or its anti-aliasing off). + */ public static float wanted() { - if (failed || !EtnaConfig.temporalActive() || EtnaClient.activeEffect() == null || !EtnaFrame.worldPassWorks()) { + if (!couldScale()) { return 1.0F; } - return Math.clamp(EtnaConfig.renderScale, EtnaConfig.RENDER_SCALE_MIN, 1.0F); + return EtnaAutoScale.scale(Math.clamp(EtnaConfig.renderScale, EtnaConfig.RENDER_SCALE_MIN, 1.0F)); + } + + /** Whether the world can be drawn smaller at all: Etna on, its anti-aliasing running, the world pass working. */ + static boolean couldScale() { + return !failed && EtnaConfig.temporalActive() && EtnaClient.activeEffect() != null && EtnaFrame.worldPassWorks(); } /** diff --git a/src/main/java/it/cosimo/etna/gui/EtnaSettingsScreen.java b/src/main/java/it/cosimo/etna/gui/EtnaSettingsScreen.java index 518b2fa..b899aca 100644 --- a/src/main/java/it/cosimo/etna/gui/EtnaSettingsScreen.java +++ b/src/main/java/it/cosimo/etna/gui/EtnaSettingsScreen.java @@ -171,6 +171,12 @@ private void generalPage() { this.add(new Slider("renderScale", EtnaConfig.RENDER_SCALE_MIN, 1.0, EtnaConfig.renderScale, v -> Component.literal(Math.round(v * 20.0) * 5 + "%"), v -> EtnaConfig.renderScale = Math.round((float) v * 20.0F) / 20.0F), "renderScale"); + // Auto Resolution: the frame rate to hold, or off (EtnaAutoScale); World Resolution is then its lowest. + this.cycle("autoResolution", + () -> EtnaConfig.autoResolutionFps > 0 + ? Component.translatable("etna.options.autoResolution.fps", EtnaConfig.autoResolutionFps) + : CommonComponents.OPTION_OFF, + () -> EtnaConfig.autoResolutionFps = nextFps(EtnaConfig.autoResolutionFps)); if (MacosUtil.IS_MACOS) { this.cycle("resolution", () -> Component.translatable(EtnaConfig.halfResolution ? "etna.options.resolution.half" : "etna.options.resolution.full"), @@ -505,6 +511,17 @@ private static String key(Enum value) { return value.name().toLowerCase(Locale.ROOT); } + /** The next of EtnaConfig.AUTO_RESOLUTION_FPS after {@code fps} (a value from the file that isn't one goes to off). */ + private static int nextFps(int fps) { + int[] values = EtnaConfig.AUTO_RESOLUTION_FPS; + for (int i = 0; i < values.length; i++) { + if (values[i] == fps) { + return values[(i + 1) % values.length]; + } + } + return 0; + } + private static Component onOff(boolean on) { return on ? CommonComponents.OPTION_ON : CommonComponents.OPTION_OFF; } diff --git a/src/main/resources/assets/etna/lang/en_us.json b/src/main/resources/assets/etna/lang/en_us.json index 6ef24cf..6e828b8 100644 --- a/src/main/resources/assets/etna/lang/en_us.json +++ b/src/main/resources/assets/etna/lang/en_us.json @@ -30,6 +30,9 @@ "etna.options.profile.custom": "Custom", "etna.options.renderScale": "World Resolution", "etna.options.renderScale.desc": "How many pixels the world is drawn at, as a share of the screen's width and height. Below 100% Etna draws the world smaller and its anti-aliasing builds the full picture back up over a few frames, the way DLSS and FSR do: much faster, a little softer, mostly in motion. 75% draws about half the pixels. The hand, the HUD and menus stay sharp. Needs anti-aliasing, which every Quality but Low has.", + "etna.options.autoResolution": "Auto Resolution", + "etna.options.autoResolution.fps": "%s fps", + "etna.options.autoResolution.desc": "Holds this frame rate by lowering the world's resolution by itself in heavy scenes (forests, big views, storms) and raising it back when there's room. It steers by how long the graphics card takes per frame, so VSync doesn't fool it. It never goes below World Resolution, or 70% when that is at 100%. Needs anti-aliasing. Off during the benchmark.", "etna.options.resolution": "Resolution", "etna.options.resolution.desc": "Half draws a quarter of the pixels and lets macOS scale the picture up: much faster, but blurry. Applies after restarting the game, and only when Etna is on as the game starts.", "etna.options.resolution.full": "Retina", diff --git a/src/main/resources/assets/etna/lang/it_it.json b/src/main/resources/assets/etna/lang/it_it.json index 98161cf..7755dc8 100644 --- a/src/main/resources/assets/etna/lang/it_it.json +++ b/src/main/resources/assets/etna/lang/it_it.json @@ -30,6 +30,9 @@ "etna.options.profile.custom": "Personalizzato", "etna.options.renderScale": "Risoluzione del mondo", "etna.options.renderScale.desc": "Quanti pixel usa il mondo, in proporzione a larghezza e altezza dello schermo. Sotto il 100% Etna disegna il mondo più piccolo e l'anti-aliasing ricostruisce l'immagine piena in pochi fotogrammi, come fanno DLSS e FSR: molto più veloce, un po' più morbido, soprattutto in movimento. Al 75% i pixel sono circa la metà. Mano, HUD e menu restano nitidi. Serve l'anti-aliasing, attivo in ogni Qualità tranne Bassa.", + "etna.options.autoResolution": "Risoluzione automatica", + "etna.options.autoResolution.fps": "%s fps", + "etna.options.autoResolution.desc": "Mantiene questi fotogrammi al secondo abbassando da sola la risoluzione del mondo nelle scene pesanti (foreste, grandi panorami, temporali) e rialzandola quando c'è margine. Si regola sul tempo che la scheda grafica impiega per ogni frame, quindi il VSync non la inganna. Non scende mai sotto la Risoluzione del mondo, o il 70% se quella è al 100%. Richiede l'anti-aliasing. Spenta durante il benchmark.", "etna.options.resolution": "Risoluzione", "etna.options.resolution.desc": "Metà disegna un quarto dei pixel e lascia che macOS ingrandisca l'immagine: molto più veloce, ma sfocata. Vale dal prossimo avvio, e solo se Etna è attivo all'avvio del gioco.", "etna.options.resolution.full": "Retina", From 859faa67f7b9b1a55bc51b358d81a98d31c1d93f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 03:52:41 +0000 Subject: [PATCH 19/24] Auto Resolution: reach the floor exactly, steer without the HUD, predict steps up by the scaled passes From an independent review: - The lowest step is the first one at or below World Resolution and draws at that floor exactly: at 95% Auto drew at 100% and could never go down. - It steers by the GPU time up to the end of Etna's final pass, without the HUD and the copy to the window, where a VSync wait can land on some drivers and would have held it at its lowest. - A step up predicts only the level and world passes growing with the pixels; the shadow pass, the hand, bloom and grade keep their cost. - No sentinel time for 'room since', frames timed while it didn't steer aren't counted, the GPU timer's doc names Auto Resolution. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01F7MajtknSApr1T68EGCPtx --- .../java/it/cosimo/etna/EtnaAutoScale.java | 57 ++++++++++++------- .../java/it/cosimo/etna/EtnaGpuTimer.java | 5 +- 2 files changed, 38 insertions(+), 24 deletions(-) diff --git a/src/main/java/it/cosimo/etna/EtnaAutoScale.java b/src/main/java/it/cosimo/etna/EtnaAutoScale.java index b639d82..aa5bd3b 100644 --- a/src/main/java/it/cosimo/etna/EtnaAutoScale.java +++ b/src/main/java/it/cosimo/etna/EtnaAutoScale.java @@ -7,12 +7,14 @@ * frame is what is steered, not the frame interval, which VSync and frame limiters would hold at their cap. * * The GPU time comes from EtnaGpuTimer, which times one frame in eight while Auto Resolution is on (as for the - * performance log). The scale moves in a few fixed steps, so the targets the world is drawn into change seldom + * performance log): the frame up to the end of Etna's final pass, without the HUD and the copy to the window, + * where a swapchain wait for VSync could land. The scale moves in a few fixed steps, so the targets the world is drawn into change seldom * and every pass still covers its whole target: * - over the budget on two timed frames in a row, one step down; far over it on one, two steps down at once; * - one step up only when every timed frame of the last two seconds would still have been well inside the budget - * at the bigger size, counting all of the frame as growing with the pixels (it doesn't: the shadow pass, the - * hand and the HUD stay as they are), so a step up does not come straight back down. + * at the bigger size, its level and world passes growing with the pixels and the rest (the shadow pass, the + * hand, bloom and grade, which run at their own sizes) staying as they are, so a step up doesn't come straight + * back down. * Frames drawn before a step are not counted after it. It never goes below the World Resolution setting (70% when * that is at 100%), never runs during the benchmark, and stays off without GPU timestamps (the frame interval * alone can't tell room to spare from a VSync cap). Everything here runs on the render thread. @@ -36,8 +38,9 @@ public final class EtnaAutoScale { /** The first frame drawn at the current step: timed frames from before it are not counted. */ private static long firstFrame; private static long changedAt; - /** Since when every timed frame would have fitted one step up. */ - private static long roomSince = -1L; + /** Whether every timed frame since roomSince would have fitted one step up. */ + private static boolean room; + private static long roomSince; private static long frame; private EtnaAutoScale() { @@ -48,10 +51,17 @@ static float scale(float fixed) { if (!active()) { step = 0; overBudget = 0; - roomSince = -1L; + room = false; + // Frames timed while it didn't steer aren't counted once it does. + firstFrame = frame + 1; return fixed; } - return Math.max(STEPS[step], floor(fixed)); + return stepScale(step, floor(fixed)); + } + + /** The scale step {@code index} draws at: the step, or the floor where the lowest step goes past it. */ + private static float stepScale(int index, float floor) { + return Math.max(STEPS[index], floor); } /** Whether Auto Resolution steers the scale now. */ @@ -73,22 +83,29 @@ private static float floor(float fixed) { return fixed < 1.0F ? fixed : DEFAULT_FLOOR; } + /** The first step at or below the floor: drawn at the floor itself (stepScale). */ private static int lowestStep(float floor) { int lowest = 0; - while (lowest + 1 < STEPS.length && STEPS[lowest + 1] >= floor - 1e-4F) { + while (lowest + 1 < STEPS.length && STEPS[lowest] > floor + 1e-4F) { lowest++; } return lowest; } - /** EtnaGpuTimer: a timed frame came back, {@code frameNumber} as EtnaProfiler numbered it. */ - static void gpuFrame(long frameNumber, long totalNs) { + /** + * EtnaGpuTimer: a timed frame came back, {@code frameNumber} as EtnaProfiler numbered it, with its GPU time per + * part in nanoseconds (EtnaGpuTimer.PASSES: shadow, level, world, hand, final, gui). + */ + static void gpuFrame(long frameNumber, long[] passNs) { if (!active() || frameNumber < firstFrame) { return; } + long totalNs = passNs[0] + passNs[1] + passNs[2] + passNs[3] + passNs[4]; + long scaledNs = passNs[1] + passNs[2]; long now = System.nanoTime(); double budget = 1.0e9 / EtnaConfig.autoResolutionFps; - int lowest = lowestStep(floor(Math.clamp(EtnaConfig.renderScale, EtnaConfig.RENDER_SCALE_MIN, 1.0F))); + float floor = floor(Math.clamp(EtnaConfig.renderScale, EtnaConfig.RENDER_SCALE_MIN, 1.0F)); + int lowest = lowestStep(floor); if (step > lowest) { // The floor went up in the settings. change(lowest, now); @@ -96,7 +113,7 @@ static void gpuFrame(long frameNumber, long totalNs) { } if (totalNs > budget) { overBudget++; - roomSince = -1L; + room = false; if (step < lowest && now - changedAt >= DOWN_AFTER_NS && (overBudget >= 2 || totalNs > budget * PANIC)) { change(Math.min(step + (totalNs > budget * PANIC ? 2 : 1), lowest), now); } @@ -106,12 +123,13 @@ static void gpuFrame(long frameNumber, long totalNs) { if (step == 0) { return; } - double grow = (double) STEPS[step - 1] / STEPS[step]; - if (totalNs * grow * grow > budget * HEADROOM) { - roomSince = -1L; + double grow = (double) stepScale(step - 1, floor) / stepScale(step, floor); + if (totalNs + scaledNs * (grow * grow - 1.0) > budget * HEADROOM) { + room = false; return; } - if (roomSince < 0L) { + if (!room) { + room = true; roomSince = now; } if (now - roomSince >= UP_AFTER_NS && now - changedAt >= UP_AFTER_NS) { @@ -122,14 +140,9 @@ static void gpuFrame(long frameNumber, long totalNs) { private static void change(int to, long now) { step = to; overBudget = 0; - roomSince = -1L; + room = false; changedAt = now; // Timed frames come back at the start of a frame, before its level is sized: this one is the first at the new step. firstFrame = frame; } - - /** For the log: the step Auto Resolution is at, or -1 while it doesn't steer. */ - static float current() { - return active() ? STEPS[step] : -1.0F; - } } diff --git a/src/main/java/it/cosimo/etna/EtnaGpuTimer.java b/src/main/java/it/cosimo/etna/EtnaGpuTimer.java index 5028d27..d2dde54 100644 --- a/src/main/java/it/cosimo/etna/EtnaGpuTimer.java +++ b/src/main/java/it/cosimo/etna/EtnaGpuTimer.java @@ -26,7 +26,8 @@ * while the GPU may still write that query, so a slot is only reused after it has come back. * * Timestamps cost a little: once a frame has one, MoltenVK makes every render pass wait for the previous one - * to finish. So the timer only runs while the performance log is on or the benchmark runs, only on frames + * to finish. So the timer only runs while the performance log is on, the benchmark runs or Auto Resolution + * steers the world's size (EtnaAutoScale), only on frames * that draw the world, and only on one frame in {@link #EVERY}, so the frame rate around it stays the normal * one. When MoltenVK cannot sample the GPU's clock it gives every query of a frame the same time; after a run * of such frames the timer says so once in the log and stops, and the log and the benchmark show CPU times @@ -351,7 +352,6 @@ private static void book(Slot slot) { if (totalNs > TOO_LONG_NS) { return; } - EtnaAutoScale.gpuFrame(slot.frame, totalNs); passNs[0] = interval(Mark.SHADOW_START, Mark.SHADOW_END); // The level is everything from the frame's start up to the world pass, but for the shadow pass inside it. passNs[1] = interval(Mark.FRAME_START, Mark.SHADOW_START) + interval(Mark.SHADOW_END, Mark.LEVEL_END); @@ -359,6 +359,7 @@ private static void book(Slot slot) { passNs[3] = interval(Mark.WORLD_END, Mark.HAND_END); passNs[4] = interval(Mark.HAND_END, Mark.FINAL_END); passNs[5] = interval(Mark.FINAL_END, Mark.FRAME_END); + EtnaAutoScale.gpuFrame(slot.frame, passNs); EtnaProfiler.gpuFrame(slot.frame, passNs, totalNs); } From 86371a9554f765968fcb271f50f310ff5f84b7d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 04:10:09 +0000 Subject: [PATCH 20/24] Etna 0.8.1: the shadow map's middle follows the camera in 2-block steps The warp is centred on the map's middle, so each time the middle moved, every texel of the map landed somewhere else on the world by a fraction of a texel and the map took every edge and every leaf's holes at new points. It moved a texel (0.1 blocks) at a time: while walking, most frames. The dappled shade under trees and the shadow edges further out shimmered for as long as the camera moved. Now the middle holds still in the world and steps after the camera once it is three quarters of a 2-block step away on an axis of the map (a hold, so it can't flick back and forth). Offline (a canopy of leaf blocks with holes, swaying, under the area-weighted filter, walking at 4.3 blocks a second) the mean change from one frame to the next drops from 0.05-0.09 to 0.004-0.008 of full shade, 16 to 150 blocks away; the frame the map steps is no worse than any frame was before. Near the camera texels get at most 7% wider (16% along the line to the middle). The shadows fade out towards the map's edge counted from the camera (etnaMapOut, EtnaShadowInfo.w: the share of the radius the map always covers round the camera), so where they end no longer jumps when the middle steps. The caster tests count from the middle (the square, the caster floor) or widen by the camera's lead (the columns, the view margins). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Xzo7zbTpyU7a3y3XQNbw1n --- src/main/java/it/cosimo/etna/EtnaShadows.java | 110 +++++++++++++++--- .../etna/shaders/include/terrain_hooks.glsl | 2 +- .../etna/shaders/include/terrain_shadow.glsl | 32 +++-- .../etna/shaders/include/terrain_ubo.glsl | 2 +- .../etna/shaders/include/terrain_water.glsl | 8 +- .../assets/etna/shaders/post/shafts.fsh | 8 +- 6 files changed, 129 insertions(+), 33 deletions(-) diff --git a/src/main/java/it/cosimo/etna/EtnaShadows.java b/src/main/java/it/cosimo/etna/EtnaShadows.java index 6196634..34ebc02 100644 --- a/src/main/java/it/cosimo/etna/EtnaShadows.java +++ b/src/main/java/it/cosimo/etna/EtnaShadows.java @@ -71,7 +71,8 @@ * shadows glide with the sun instead of shimmering along their edges. * * Before the main pass, the chunk sections that can shadow what the camera sees are drawn a second time - * into a depth texture, looking along the light, with an orthographic projection centred on the camera: + * into a depth texture, looking along the light, with an orthographic projection centred near the camera + * (its middle follows the camera in steps of about two blocks, MIDDLE_STEP_BLOCKS): * solid terrain, then cutout terrain, nearest the light first, so the GPU shades as little of it as it * can. The * solid, cutout and classic-translucent terrain pipelines are then swapped for copies that also bind @@ -187,6 +188,28 @@ public boolean bilinear() { */ private static final float RESTART_ANGLE = 0.035F; private static final double RESTART_DISTANCE = 16.0; + /** + * The map's middle follows the camera this many blocks at a time (rounded to whole texels of the map), and + * only once the camera is MIDDLE_HOLD of a step away from it on either axis of the map: then it moves to the + * step nearest the camera, so it can't flick back and forth while the camera stands near the edge of one. + * The warp (shadow_distortion.glsl) is centred on the middle, so wherever the middle moves, every texel of the + * map lands somewhere else on the world, by some fraction of a texel: the map takes every edge and every + * leaf's holes at new points. Until 0.8.1 it followed a texel at a time, which while walking is most frames, + * and the dappled shade under trees and the edges further out shimmered as long as the camera moved. Now + * the map holds still for two blocks of walking, about half a second, and steps once. The camera stays + * within about two blocks of the middle, where the warp has made texels 7% wider than right on it (16% + * along the line to the middle). + */ + private static final double MIDDLE_STEP_BLOCKS = 2.0; + private static final double MIDDLE_HOLD = 0.75; + /** + * How far a map's middle can be from the camera when the terrain reads it, on each axis, past the hold above + * (a map is read at most a frame after it was drawn, while the maps fade: a camera flying at speed moves up + * to a block or two in that frame). The shadows fade out by the same distance from the camera on every side + * (EtnaShadowInfo.w), this much inside the map's edge, so wherever the middle is they fade out at the same + * place and never meet the map's edge. + */ + private static final double MIDDLE_SLACK_BLOCKS = 2.0; /** * While the maps fade, each is drawn every other frame and read on the next one too, after the camera may * have turned: its casters are then kept for a screen this many screen widths wider on every side (about @@ -255,6 +278,16 @@ public boolean bilinear() { /** The level the maps were drawn in, held weakly: after leaving a world the menus must not keep it alive. */ private static WeakReference<@Nullable Object> stepLevel = new WeakReference<>(null); private static float stepRadius; + /** + * Each map's middle, in blocks along the map's x and y axes from the world's origin (MIDDLE_STEP_BLOCKS), for + * the direction and step it was found for: another direction or step finds it afresh. NaN until a map is drawn. + */ + private static final double[][] middles = {{Double.NaN, Double.NaN}, {Double.NaN, Double.NaN}}; + private static final Vector3f[] middleDirs = {new Vector3f(), new Vector3f()}; + private static final double[] middleSteps = new double[2]; + /** The camera's place from the middle of the map being drawn, along the map's x and y axes, in blocks. */ + private static float middleLeadX; + private static float middleLeadY; /** The extra screen widths this frame's map keeps casters for (FADE_SLACK while fading), and what it got. */ private static float drawSlack; private static float drawnSlack; @@ -443,14 +476,14 @@ private static boolean renderShadowMap(LevelRenderer levelRenderer, LevelRenderS float radius = EtnaConfig.shadowDistance; Matrix4f rotation = new Matrix4f().lookAlong(-light.x, -light.y, -light.z, 0.0F, 0.0F, 1.0F); - // Snap the map to its own texel grid in world space, so shadow edges don't crawl as you move. + // The map's middle holds still in the world and follows the camera in steps (MIDDLE_STEP_BLOCKS). Vec3 cam = state.cameraRenderState.pos; - double texel = 2.0 * radius / size; double lx = cam.x * rotation.m00() + cam.y * rotation.m10() + cam.z * rotation.m20(); double ly = cam.x * rotation.m01() + cam.y * rotation.m11() + cam.z * rotation.m21(); - float snapX = (float) (lx - Math.floor(lx / texel) * texel); - float snapY = (float) (ly - Math.floor(ly / texel) * texel); - Matrix4f shadowView = new Matrix4f().translation(snapX, snapY, 0.0F).mul(rotation); + double[] middle = followMiddle(map, light, middleStep(radius, size), lx, ly); + middleLeadX = (float) (lx - middle[0]); + middleLeadY = (float) (ly - middle[1]); + Matrix4f shadowView = new Matrix4f().translation(middleLeadX, middleLeadY, 0.0F).mul(rotation); boolean zeroToOne = RenderSystem.getDevice().getDeviceInfo().isZZeroToOne(); // Reversed depth like the rest of the game: near and far swapped, so 1 is closest to the light. @@ -498,6 +531,41 @@ private static boolean renderShadowMap(LevelRenderer levelRenderer, LevelRenderS return true; } + /** How far the map's middle moves at a time, in blocks: MIDDLE_STEP_BLOCKS rounded to whole texels of the map. */ + private static double middleStep(float radius, int size) { + double texel = 2.0 * radius / size; + return texel * Math.max(1L, Math.round(MIDDLE_STEP_BLOCKS / texel)); + } + + /** + * Map `map`'s middle for a camera at lx, ly along its axes (light): where it was, unless the camera has gone + * MIDDLE_HOLD of a step past it on an axis, or the map now looks along another direction or the step changed + * (another map size or shadow distance); then the step nearest the camera on that axis (on both, for a new + * direction or step). A camera far away (a teleport) is far past any hold. + */ + private static double[] followMiddle(int map, Vector3f light, double step, double lx, double ly) { + double[] middle = middles[map]; + boolean fresh = middleSteps[map] != step || !middleDirs[map].equals(light) || !Double.isFinite(middle[0]) || !Double.isFinite(middle[1]); + double hold = MIDDLE_HOLD * step; + if (fresh || !(Math.abs(lx - middle[0]) <= hold)) { + middle[0] = Math.rint(lx / step) * step; + } + if (fresh || !(Math.abs(ly - middle[1]) <= hold)) { + middle[1] = Math.rint(ly / step) * step; + } + middleSteps[map] = step; + middleDirs[map].set(light); + return middle; + } + + /** + * The farthest the camera can be from a map's middle on either axis when the terrain reads that map, in + * blocks: the hold, and a frame of flying (MIDDLE_SLACK_BLOCKS). + */ + private static float middleLeadMax(float radius, int size) { + return (float) (MIDDLE_HOLD * middleStep(radius, size) + MIDDLE_SLACK_BLOCKS); + } + /** * Which map to draw this frame. Turned with the sun every frame, the map moved every shadow edge by a whole * texel at a time, here and there along it, as the sun crept on (the area-weighted filter smooths how a @@ -505,7 +573,7 @@ private static boolean renderShadowMap(LevelRenderer levelRenderer, LevelRenderS * day. Now a map's direction holds still until the sun has moved the shadow of something STEP_HEIGHT * blocks up by STEP_SHIFT. Then the other map is drawn along the sun's new direction and the terrain fades * over to it: between two maps that hold still, an edge glides instead of flickering. Held still, a map - * drawn again every frame comes out the same, texel for texel: the camera's moves are snapped to its grid. + * drawn again every frame comes out the same, texel for texel, while its middle holds still (followMiddle). * While the two fade they take turns being drawn, so each is at most a frame old (settleMaps moves the older * one to this frame's camera) and the shadow pass costs what one map did. A fade starts by drawing the held * map once more, so neither map is ever read on a frame after the one it was drawn for unless its casters @@ -732,8 +800,10 @@ private static void collectCasters(@Nullable ViewArea area, Vec3 cam, Matrix4f r } out.clear(); // A section's centre may sit up to half its diagonal (~13.9 blocks) outside the volume and still - // reach into it. - float reach = radius + 14.0F; + // reach into it. The volume is the map's square round its middle; reach counts from the camera, which can + // be up to a few blocks from the middle (MIDDLE_STEP_BLOCKS). + float lead = Math.max(Math.abs(middleLeadX), Math.abs(middleLeadY)); + float reach = radius + 14.0F + lead; int sectionRadius = Mth.ceil(reach / 16.0F); int camX = SectionPos.blockToSectionCoord(cam.x); int camZ = SectionPos.blockToSectionCoord(cam.z); @@ -780,7 +850,7 @@ private static void collectCasters(@Nullable ViewArea area, Vec3 cam, Matrix4f r float z = (float) (pos.getZ() + 8 - cam.z); center.set(x, y, z); rotation.transformDirection(center); - if (Math.abs(center.x) > reach || Math.abs(center.y) > reach) { + if (Math.abs(center.x + middleLeadX) > radius + 14.0F || Math.abs(center.y + middleLeadY) > radius + 14.0F) { continue; } if (viewTest && !CasterView.keeps(x, y, z, center.x, center.y)) { @@ -895,6 +965,9 @@ private static int receiverFloor(Vec3 cam, Matrix4f rotation, float radius, int float lx = r00 * dx + r10 * dy + r20 * dz; float ly = r01 * dx + r11 * dy + r21 * dz; int margin = FLOOR_MARGIN; + // From the map's middle rather than the camera. + lx += middleLeadX; + ly += middleLeadY; if (Math.abs(lx) <= mapEdge && Math.abs(ly) <= mapEdge) { // Read through the map, so filtered there: its texels are largest at its lookup farthest from the // middle of the map, which is its own farthest point pushed out once more (in map units; the @@ -1115,8 +1188,8 @@ private static void addPlane(float x, float y, float z, float w, float towardsLi private static boolean setMargins(float radius, int size, float softness, float reach) { float texel = 2.0F * radius / size; float edgeTexel = texel / (1.0F - DISTORTION); - // The map's middle is snapped to its texel grid, up to a texel's diagonal from the camera. - float snap = DIAGONAL * texel; + // How far the map's middle is from the camera (it follows the camera in steps, MIDDLE_STEP_BLOCKS). + float snap = (float) Math.sqrt(middleLeadX * middleLeadX + middleLeadY * middleLeadY); // In texels: the shadow filter's square (softness texels each way, so the texels it weighs have their // centres up to softness + 0.5 away on each axis) and its push off the surface (1.2 texels); the // light through leaves reads 1.5 texels each way around a point moved towards the light. @@ -1484,7 +1557,7 @@ private static void writeUbo(Vector3f light, float strength, int size, boolean d Std140Builder builder = Std140Builder.onStack(stack, UBO_SIZE) .putMat4f(drawn ? primaryMatrix : NO_MAP) .putVec4(new Vector4f(light.x, light.y, light.z, strength)) - .putVec4(new Vector4f(1.0F / size, EtnaConfig.shadowDistance, 1.0F / (2.0F * DEPTH_RANGE), EtnaConfig.lightRays.value)) + .putVec4(new Vector4f(1.0F / size, EtnaConfig.shadowDistance, 1.0F / (2.0F * DEPTH_RANGE), shadowReach(size))) .putVec4(new Vector4f(EtnaConfig.shadowSoftness.value, EtnaConfig.shadowDarknessShown(), EtnaConfig.sunlight.value, EtnaConfig.haze.value)) .putVec4(new Vector4f(EtnaConfig.waterReflections.value, EtnaConfig.warmth.value, EtnaFrame.frame() % 4096, drawn ? 1.0F : 0.0F)) .putVec4(new Vector4f(faces.down(), faces.up(), 0.5F * (faces.north() + faces.south()), 0.5F * (faces.west() + faces.east()))) @@ -1512,6 +1585,17 @@ private static void writeUbo(Vector3f light, float strength, int size, boolean d } } + /** + * How far round the camera, on each axis, the map always reaches, as a share of its radius (EtnaShadowInfo.w): + * its middle is up to middleLeadMax blocks from the camera. The terrain fades the shadows out towards this + * (etnaMapOut in terrain_shadow.glsl), counted from the camera, so they end at the same distance whichever + * step the middle is at. + */ + private static float shadowReach(int size) { + float radius = Math.max(EtnaConfig.shadowDistance, 1.0F); + return Math.max(1.0F - middleLeadMax(radius, size) / radius, 0.5F); + } + /** * The block as this frame wrote it, for the lit clouds (EtnaClouds), or null while it is not ready: Etna * off, or the terrain lighting broken. diff --git a/src/main/resources/assets/etna/shaders/include/terrain_hooks.glsl b/src/main/resources/assets/etna/shaders/include/terrain_hooks.glsl index 0fb3da2..450967a 100644 --- a/src/main/resources/assets/etna/shaders/include/terrain_hooks.glsl +++ b/src/main/resources/assets/etna/shaders/include/terrain_hooks.glsl @@ -9,7 +9,7 @@ float shadowVisibility(vec3 normal, bool thin); // shadeTerrain: filtered shadow float shadowAt(vec3 position); // sunGlint, waterFromBelow, etnaWaterPrepare: unfiltered lookup at a camera-relative point float shadowAtSample(vec3 position, float pick); // underwaterShafts: the same, one map per jittered sample float shadowSoftAt(vec3 position); // waterBodyShadow: the same, with the four texels round the point blended -vec4 shadowSpace(vec3 position); // a camera-relative point in the map: xy uv, z expected depth, w how far out (1 at the edge) +vec4 shadowSpace(vec3 position); // a camera-relative point in the map: xy uv, z expected depth, w how far out from the camera (1 at the edge, etnaMapOut) float leafBacklight(); // leafGlow: sunlight through the leaves to this one, 0 .. 1 // terrain_clouds.glsl: the clouds' shadows, in the same draws. float etnaCloudShadow(); // shadeTerrain: direct light the clouds let through, 1 in the open .. 1 - strength under a cloud diff --git a/src/main/resources/assets/etna/shaders/include/terrain_shadow.glsl b/src/main/resources/assets/etna/shaders/include/terrain_shadow.glsl index 173854e..0193325 100644 --- a/src/main/resources/assets/etna/shaders/include/terrain_shadow.glsl +++ b/src/main/resources/assets/etna/shaders/include/terrain_shadow.glsl @@ -15,6 +15,17 @@ #define ETNA_SHADOW_GATHER #endif +// How far out a point lies in the shadow map `shadowMat` draws, from its undistorted clip x and y: the larger of +// its distances from the camera along the map's two axes (the camera sits at the matrix's last column, the +// projection being orthographic), over the share of the map that always lies round the camera on every side +// (EtnaShadowInfo.w). 0 at the camera, 1 where that share ends, a little inside the map's own edge. The map's +// middle follows the camera in steps of about two blocks (EtnaShadows.MIDDLE_STEP_BLOCKS); counted from the +// camera, the shadows fade out at the same distance whichever step it is at, instead of jumping by a step. +float etnaMapOut(vec2 ndc, mat4 shadowMat) { + vec2 away = abs(ndc - shadowMat[3].xy); + return max(away.x, away.y) / (EtnaShadowInfo.w > 0.0 ? EtnaShadowInfo.w : 1.0); +} + // Size of a shadow texel at this spot of the map, in blocks, along its coarser (radial) direction. float etnaShadowTexel(float factor) { float texelBlocks = 2.0 * EtnaShadowInfo.y * EtnaShadowInfo.x; @@ -148,7 +159,7 @@ float etnaShadowFilter(sampler2D map, mat4 shadowMat, vec3 normal, bool thin) { vec3 offset = thin ? EtnaLightDir.xyz * texelBlocks * 1.5 : normal * texelBlocks * 1.2; vec4 clip = shadowMat * vec4(etnaViewPos + offset, 1.0); vec3 ndc = clip.xyz / clip.w; - float edge = max(abs(ndc.x), abs(ndc.y)); + float edge = etnaMapOut(ndc.xy, shadowMat); if (edge >= 1.0) { return 1.0; } @@ -316,25 +327,24 @@ float shadowVisibility(vec3 normal, bool thin) { return mix(lit, 1.0, max(etnaLowSunFill(), etnaCasterFade(etnaViewPos))); } -// shadowSpace for a point already in the map's clip space (EtnaShadowMat applied; the projection is +// shadowSpace for a point already in the clip space of the map `shadowMat` draws (the projection is // orthographic, so w is 1): the distortion and the depth range, which are the same for every caller. -vec4 etnaShadowMapPoint(vec3 ndc) { +vec4 etnaShadowMapPoint(vec3 ndc, mat4 shadowMat) { #ifdef RENDERPEARL_DEPTH_IS_ZERO_TO_ONE float depth = ndc.z; #else float depth = ndc.z * 0.5 + 0.5; #endif - return vec4(ndc.xy / etnaShadowFactor(ndc.xy) * 0.5 + 0.5, depth, max(abs(ndc.x), abs(ndc.y))); + return vec4(ndc.xy / etnaShadowFactor(ndc.xy) * 0.5 + 0.5, depth, etnaMapOut(ndc.xy, shadowMat)); } // Where a camera-relative point lands in the shadow map, without filtering. xy: its texture coordinates // in the (distorted) map; z: the depth the map stores for something at that point (reversed: bigger is -// closer to the light); w: how far out in the map it lies, the larger of its undistorted |x| and |y|: -// 0 at the centre, 1 at the edge of the area the map covers, above 1 outside it, where xy and z mean -// nothing. +// closer to the light); w: how far out in the map it lies (etnaMapOut): 0 at the camera, 1 at the edge of +// the area the map always covers round it, above 1 outside it, where xy and z may mean nothing. vec4 shadowSpace(vec3 position) { vec4 clip = EtnaShadowMat * vec4(position, 1.0); - return etnaShadowMapPoint(clip.xyz / clip.w); + return etnaShadowMapPoint(clip.xyz / clip.w, EtnaShadowMat); } // The unfiltered test in `map` for a point from shadowSpace (or the same for the second map): 1 lit, 0 in @@ -350,7 +360,7 @@ float etnaShadowTest(sampler2D map, vec4 point) { // The unfiltered test for a camera-relative point in the second map, the one the terrain fades to. float etnaShadowTest2(vec3 position) { vec4 clip = EtnaShadowMat2 * vec4(position, 1.0); - return etnaShadowTest(EtnaShadowSampler2, etnaShadowMapPoint(clip.xyz / clip.w)); + return etnaShadowTest(EtnaShadowSampler2, etnaShadowMapPoint(clip.xyz / clip.w, EtnaShadowMat2)); } // Shadow map lookup for a camera-relative point, without filtering: 1 lit, 0 in shadow. While the two @@ -388,7 +398,7 @@ float shadowSoftAt(vec3 position) { float lit = etnaShadowSoft(EtnaShadowSampler, shadowSpace(position)); if (EtnaShadowFade.x > 0.0) { vec4 clip = EtnaShadowMat2 * vec4(position, 1.0); - lit = mix(lit, etnaShadowSoft(EtnaShadowSampler2, etnaShadowMapPoint(clip.xyz / clip.w)), EtnaShadowFade.x); + lit = mix(lit, etnaShadowSoft(EtnaShadowSampler2, etnaShadowMapPoint(clip.xyz / clip.w, EtnaShadowMat2)), EtnaShadowFade.x); } return mix(lit, 1.0, max(etnaLowSunFill(), etnaCasterFade(position))); } @@ -411,7 +421,7 @@ float shadowAtSample(vec3 position, float pick) { float etnaLeafThrough(sampler2D map, mat4 shadowMat) { vec4 clip = shadowMat * vec4(etnaViewPos + EtnaLightDir.xyz * 0.3, 1.0); vec3 ndc = clip.xyz / clip.w; - float edge = max(abs(ndc.x), abs(ndc.y)); + float edge = etnaMapOut(ndc.xy, shadowMat); if (edge >= 1.0) { return 1.0; } diff --git a/src/main/resources/assets/etna/shaders/include/terrain_ubo.glsl b/src/main/resources/assets/etna/shaders/include/terrain_ubo.glsl index b276016..564575a 100644 --- a/src/main/resources/assets/etna/shaders/include/terrain_ubo.glsl +++ b/src/main/resources/assets/etna/shaders/include/terrain_ubo.glsl @@ -9,7 +9,7 @@ layout(std140) uniform EtnaShadow { mat4 EtnaShadowMat; // camera-relative position -> shadow map clip space vec4 EtnaLightDir; // xyz: direction towards the sun (or the moon at night), w: light strength - vec4 EtnaShadowInfo; // x: 1 / map size, y: covered radius in blocks, z: depth units per block, w: the light rays setting (read by no shader since the shafts became a post pass, which takes it from EtnaSettings) + vec4 EtnaShadowInfo; // x: 1 / map size, y: covered radius in blocks, z: depth units per block, w: the share of the radius the map always covers round the camera, on each axis (EtnaShadows.shadowReach; up to 0.8.1 the light rays setting, read by no shader) vec4 EtnaLook; // settings menu. x: shadow softness in texels, y: shadow darkness, z: sunlight, w: distance haze vec4 EtnaLook2; // x: water reflections, y: warmth (-1 cool .. 1 warm), z: frame counter, w: 1 when the shadow map was drawn this frame vec4 EtnaFaceShade; // vanilla's fixed shading per face in this dimension. x: down, y: up, z: north/south, w: west/east diff --git a/src/main/resources/assets/etna/shaders/include/terrain_water.glsl b/src/main/resources/assets/etna/shaders/include/terrain_water.glsl index cf886e9..485f915 100644 --- a/src/main/resources/assets/etna/shaders/include/terrain_water.glsl +++ b/src/main/resources/assets/etna/shaders/include/terrain_water.glsl @@ -509,7 +509,7 @@ float waterThickness(vec3 surfacePos, out float found, out float trust) { // The map's projection is orthographic: a point moved on the water's plane moves its clip position // by the matrix's x and z columns, with no second trip through the matrix. vec3 clip = (EtnaShadowMat * vec4(surfacePos, 1.0)).xyz; - vec4 map = etnaShadowMapPoint(clip); + vec4 map = etnaShadowMapPoint(clip, EtnaShadowMat); if (map.w >= 1.0) { return 0.0; } @@ -540,7 +540,7 @@ float waterThickness(vec3 surfacePos, out float found, out float trust) { float ringWeight = 0.0; for (int i = 0; i < 16; i++) { vec3 tap = THICKNESS_RING[i]; - vec4 p = etnaShadowMapPoint(clip + alongX * tap.x + alongZ * tap.y); + vec4 p = etnaShadowMapPoint(clip + alongX * tap.x + alongZ * tap.y, EtnaShadowMat); if (p.w < 1.0) { float stored = texelFetch(EtnaShadowSampler, clamp(ivec2(p.xy * size), ivec2(0), last), 0).r; // Land beside the water stands above its plane and shade holds what casts it: neither counts. @@ -670,7 +670,7 @@ float shoreFoam(vec2 p, float t) { return 0.0; } vec3 clip = (EtnaShadowMat * vec4(etnaViewPos, 1.0)).xyz; - vec4 here = etnaShadowMapPoint(clip); + vec4 here = etnaShadowMapPoint(clip, EtnaShadowMat); if (here.w >= 1.0) { return 0.0; } @@ -689,7 +689,7 @@ float shoreFoam(vec2 p, float t) { if (i == 4) { continue; } - vec4 m = etnaShadowMapPoint(clip + alongX * (toMiddle.x + o.x) + alongZ * (toMiddle.y + o.y)); + vec4 m = etnaShadowMapPoint(clip + alongX * (toMiddle.x + o.x) + alongZ * (toMiddle.y + o.y), EtnaShadowMat); if (m.w >= 1.0) { continue; } diff --git a/src/main/resources/assets/etna/shaders/post/shafts.fsh b/src/main/resources/assets/etna/shaders/post/shafts.fsh index ce966dd..59df08c 100644 --- a/src/main/resources/assets/etna/shaders/post/shafts.fsh +++ b/src/main/resources/assets/etna/shaders/post/shafts.fsh @@ -54,10 +54,12 @@ const ivec2 SUBPIXEL[4] = ivec2[4](ivec2(0, 0), ivec2(1, 1), ivec2(1, 0), ivec2( // 1 where the sun reaches a point of the ray, given as a position in the shadow map's clip space, 0 where // something nearer the light blocks it. The same test and bias as the terrain's unfiltered lookups -// (terrain_shadow.glsl), and outside the map, or fading towards its edge, the point counts as lit. +// (terrain_shadow.glsl), and outside the map, or fading towards its edge, the point counts as lit. How far +// out it lies is counted from the camera, as the terrain's etnaMapOut does, so the shafts end where the shadows do. float sunAt(vec3 clip) { - float edge = max(abs(clip.x), abs(clip.y)); - if (edge >= 1.0) { + vec2 away = abs(clip.xy - EtnaShadowMat[3].xy); + float edge = max(away.x, away.y) / (EtnaShadowInfo.w > 0.0 ? EtnaShadowInfo.w : 1.0); + if (edge >= 1.0 || max(abs(clip.x), abs(clip.y)) >= 1.0) { return 1.0; } vec2 uv = clip.xy / etnaShadowFactor(clip.xy) * 0.5 + 0.5; From bcaa65b8687580f4481cc6257abe489ac513ccd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 06:41:56 +0000 Subject: [PATCH 21/24] Etna 0.8.1: a point off the shadow map counts as past its edge etnaMapOut now returns at least 1 for a point outside the map itself, so every reader (filter, leaf light, single and bilinear tests, water) treats it as past the edge, as post/shafts.fsh already did. It only happens while the maps fade, when the older map is read a frame late behind a camera flying faster than the slack a frame; those points were nearly faded out but read the map's clamped edge texels. The middle-step comment now gives the texel growth for both the 160 and the 96-block distance, and says how much shorter the shadows end. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Xzo7zbTpyU7a3y3XQNbw1n --- src/main/java/it/cosimo/etna/EtnaShadows.java | 6 ++++-- .../assets/etna/shaders/include/terrain_shadow.glsl | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/java/it/cosimo/etna/EtnaShadows.java b/src/main/java/it/cosimo/etna/EtnaShadows.java index 34ebc02..e1d5eba 100644 --- a/src/main/java/it/cosimo/etna/EtnaShadows.java +++ b/src/main/java/it/cosimo/etna/EtnaShadows.java @@ -197,8 +197,10 @@ public boolean bilinear() { * leaf's holes at new points. Until 0.8.1 it followed a texel at a time, which while walking is most frames, * and the dappled shade under trees and the edges further out shimmered as long as the camera moved. Now * the map holds still for two blocks of walking, about half a second, and steps once. The camera stays - * within about two blocks of the middle, where the warp has made texels 7% wider than right on it (16% - * along the line to the middle). + * within a block and a half of the middle on each axis, where the warp has made texels at most 7% wider + * than right on it at a 160-block shadow distance (15% along the line to the middle), and 12% (26%) at 96: + * a few thousandths of a block. The shadows end MIDDLE_HOLD of a step plus MIDDLE_SLACK_BLOCKS, about + * three and a half blocks, short of the shadow distance (shadowReach), so where they end holds still too. */ private static final double MIDDLE_STEP_BLOCKS = 2.0; private static final double MIDDLE_HOLD = 0.75; diff --git a/src/main/resources/assets/etna/shaders/include/terrain_shadow.glsl b/src/main/resources/assets/etna/shaders/include/terrain_shadow.glsl index 0193325..dea83e9 100644 --- a/src/main/resources/assets/etna/shaders/include/terrain_shadow.glsl +++ b/src/main/resources/assets/etna/shaders/include/terrain_shadow.glsl @@ -21,9 +21,12 @@ // (EtnaShadowInfo.w). 0 at the camera, 1 where that share ends, a little inside the map's own edge. The map's // middle follows the camera in steps of about two blocks (EtnaShadows.MIDDLE_STEP_BLOCKS); counted from the // camera, the shadows fade out at the same distance whichever step it is at, instead of jumping by a step. +// A point off the map itself counts as past the edge too: while the maps fade, the older one is read a frame +// after it was drawn, and a camera flying faster than EtnaShadows.MIDDLE_SLACK_BLOCKS a frame can leave it. float etnaMapOut(vec2 ndc, mat4 shadowMat) { vec2 away = abs(ndc - shadowMat[3].xy); - return max(away.x, away.y) / (EtnaShadowInfo.w > 0.0 ? EtnaShadowInfo.w : 1.0); + float reach = max(away.x, away.y) / (EtnaShadowInfo.w > 0.0 ? EtnaShadowInfo.w : 1.0); + return max(abs(ndc.x), abs(ndc.y)) < 1.0 ? reach : max(reach, 1.0); } // Size of a shadow texel at this spot of the map, in blocks, along its coarser (radial) direction. From d56e7fcf7512c0c30eae610d255d10683590c1aa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 06:48:24 +0000 Subject: [PATCH 22/24] Etna 0.8.1: leaves and plants sway in the shadow map only near the camera From 20 to 44 blocks out the shadow pass fades the sway out. Further away a texel of the map is wider than the sway, which then only flips leaf texels between lit and shaded every frame: far tree shade twinkled even with the camera still. On screen the sway is under a pixel there anyway, and the leaves themselves keep swaying, by less than their shadow lookup's offset and bias (1.5 texels along the light plus the thin bias). Offline (leaflab, swaying leaf blocks with holes, area-weighted filter, the 2-block map steps): with the camera still the change per frame at 32-150 blocks goes from 0.003-0.004 to none; walking it drops by about 40%, the rest coming from the map's steps. Average shade unchanged. The far vertices also skip the wind maths. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Xzo7zbTpyU7a3y3XQNbw1n --- .../assets/etna/shaders/core/terrain.vsh | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/main/resources/assets/etna/shaders/core/terrain.vsh b/src/main/resources/assets/etna/shaders/core/terrain.vsh index 82b5092..64b7fad 100644 --- a/src/main/resources/assets/etna/shaders/core/terrain.vsh +++ b/src/main/resources/assets/etna/shaders/core/terrain.vsh @@ -76,6 +76,16 @@ const int TAG_MATERIAL = 200; // Leaves sway 7/16 as far as a plant's free end, which EtnaFoliage gives the edge of a plant hanging from leaves. const float PLANT_SWAY = 0.11; const float LEAF_SWAY = PLANT_SWAY * 7.0 / 16.0; +#ifdef ETNA_SHADOW_PASS +// In the shadow map leaves and plants only sway near the camera. Further out a texel of the map is wider than +// the sway (0.07 of a block at 32 blocks, at a 160-block shadow distance and a 3072 map; more with a smaller +// map), and a sway the map can't follow only flips its leaf texels between lit and shaded: far leaf shade +// twinkled frame after frame even with the camera still. On screen, from about 40 blocks out the sway is less +// than a pixel anyway. Between these distances from the camera the shadows' sway fades out; the leaves +// themselves still sway, by less than the offset that keeps them from shadowing themselves (terrain_shadow.glsl). +const float SHADOW_SWAY_NEAR = 20.0; +const float SHADOW_SWAY_FAR = 44.0; +#endif // GameTime starts again every 24000 ticks (20 minutes). Every motion below repeats a whole number of times // in that span (its rate a multiple of LOOP, in radians a second), so foliage never jumps when it does. @@ -87,7 +97,7 @@ const float LOOP = 6.2831853 / 1200.0; // than all at once. Along the wind the pattern repeats every GUST_CELLS patches (4800 blocks), the // distance the wind covers in 20 minutes, so it carries on unbroken when GameTime starts again. Only // GameTime and the world position go in: the shadow pass, which has no Etna block, moves its leaves -// exactly like the main pass. +// exactly like the main pass (near the camera: SHADOW_SWAY_NEAR). const vec2 WIND = vec2(0.9285, 0.3714); const vec2 GUST_SIZE = vec2(16.0, 28.0); const float GUST_SPEED = 4.0; @@ -140,19 +150,27 @@ void main() { float time = GameTime * 1200.0; float material = 0.0; + // How much of the sway this draw gives leaves and plants: all of it, but in the shadow map only near the camera. + #ifdef ETNA_SHADOW_PASS + float swayed = 1.0 - smoothstep(SHADOW_SWAY_NEAR, SHADOW_SWAY_FAR, length(pos)); + #else + const float swayed = 1.0; + #endif // The tag goes back to an alpha of 1, as vanilla has it, for the alpha test and everything after. vec4 color = Color; int tag = int(Color.a * 255.0 + 0.5); if (tag == TAG_LEAVES) { material = 1.0; color.a = 1.0; - pos += waveOffset(world, time, LEAF_SWAY); + if (swayed > 0.0) { + pos += waveOffset(world, time, LEAF_SWAY * swayed); + } } else if (tag >= TAG_PLANT_SHADED && tag < TAG_LEAVES) { // 2 on a face vanilla shaded as if it faced up, 2.25 on one it shaded by the way it faces (terrain.fsh). bool asUp = tag >= TAG_PLANT; material = asUp ? 2.0 : 2.25; color.a = 1.0; - float sway = float(tag - (asUp ? TAG_PLANT : TAG_PLANT_SHADED)) / 16.0; + float sway = float(tag - (asUp ? TAG_PLANT : TAG_PLANT_SHADED)) / 16.0 * swayed; if (sway > 0.0) { pos += waveOffset(world, time, PLANT_SWAY * sway); } From de2c0ab96bee6b5b6e5451e2bae926b522ae4675 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 10:31:23 +0000 Subject: [PATCH 23/24] Sky: finer Milky Way grain Two fine noise octaves (9x and 15x the base) give the star clouds and the dust lanes' edges detail down to about three pixels at 1080p; before, the smallest detail was about 14 pixels and the band looked low resolution. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01F7MajtknSApr1T68EGCPtx --- .../resources/assets/etna/shaders/core/sky.fsh | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/main/resources/assets/etna/shaders/core/sky.fsh b/src/main/resources/assets/etna/shaders/core/sky.fsh index 710a5d5..aa21ef5 100644 --- a/src/main/resources/assets/etna/shaders/core/sky.fsh +++ b/src/main/resources/assets/etna/shaders/core/sky.fsh @@ -197,7 +197,9 @@ vec3 star(vec3 dir, float pixelAngle, float seconds, float grid, float density, // The Milky Way on the star sphere: a soft band brightest towards its core, mottled with star clouds and // cut lengthwise by dark dust lanes; bluish along the arms, warm in the core. nebula (0 .. 1) adds faint // rose and teal gas clouds along it. Coordinates are galactic longitude and latitude, so the noise -// stretches along the band and never meets a seam; nothing in it is smaller than a few pixels. +// stretches along the band and never meets a seam. Two fine octaves (9 and 15 times the base) give the star +// clouds and the dust lanes' edges grain down to about three pixels at 1080p, so the band reads as countless +// stars and dust rather than a few soft blobs; finer than that would shimmer as the sky turns. vec3 milkyWay(vec3 starDir, float b, float nebula) { float along = atan(dot(starDir, GALAXY_SIDE), dot(starDir, GALAXY_CENTRE)); float toCentre = dot(starDir, GALAXY_CENTRE); @@ -207,17 +209,23 @@ vec3 milkyWay(vec3 starDir, float b, float nebula) { // 0.75 so the dust tiles a whole number of times round the band (BAND_TILES * 0.75 = 3), with no seam // where the band's coordinate wraps. vec4 dustNoise = noise(vec2(uv.x * 0.75, uv.y * 3.0) + vec2(0.71, 0.13)); + // Whole multiples, so the fine octaves meet themselves round the band too. + vec4 detail = noise(uv * 9.0 + vec2(0.23, 0.89)); + vec4 grain = noise(uv * 15.0 + vec2(0.53, 0.17)); + float finest = 0.6 * detail.b + 0.4 * grain.a; float band = bandOf(b) * (0.55 + 0.45 * smoothstep(-0.6, 1.0, toCentre)); float core = exp(-(1.0 - toCentre) * 7.0) * exp(-b * b * (0.5 / (CORE_WIDTH * CORE_WIDTH))); - float clouds = 0.7 * big.r + 0.3 * fine.b; + float clouds = 0.55 * big.r + 0.25 * fine.b + 0.2 * detail.a; float light = band * (0.4 + 0.75 * smoothstep(0.25, 0.8, clouds)) + 1.5 * core * (0.7 + 0.45 * fine.a); + // The grain of unresolved stars: about as bright on average, much finer. + light *= 0.7 + 0.6 * finest; // The dust lanes: a long, wavering dark rift down the middle of the band, denser in some places than // others and widest over the core. float offset = (dustNoise.g - 0.5) * 0.07; float width = DUST_WIDTH * (1.0 + 0.8 * core) * (0.7 + 0.6 * dustNoise.b); float rift = exp(-(b - offset) * (b - offset) * (0.5 / (width * width))); - float lanes = rift * (0.3 + 0.7 * smoothstep(0.35, 0.68, dustNoise.r)); + float lanes = rift * (0.3 + 0.7 * smoothstep(0.35, 0.68, dustNoise.r + 0.3 * (detail.g - 0.5) + 0.2 * (grain.r - 0.5))); light *= 1.0 - 0.72 * lanes; vec3 color = mix(vec3(0.62, 0.70, 1.0), vec3(1.0, 0.88, 0.74), min(core * 1.5, 1.0)) * light; if (nebula > 0.0) { From 98af66f567f70bd0158cff777209052917a0926f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 10:31:34 +0000 Subject: [PATCH 24/24] Etna 0.8.1: water shade lines up with the bottom; far water less white waterBodyShadow follows the straight line of sight into the water instead of the refracted ray. The bottom shows through the water unbent (the blend lays the water over it), so the shade in the water now lands on the shadow seen on the bottom. Before, the two sat side by side: shaded water over a lit bottom looked colourless and see-through, lit water over a shaded bottom like a patch of colour. Far water seen nearly edge on: the waves a pixel no longer resolves still tilt the facets it sees towards the eye (HIDDEN_TILT, 0.08), so they mirror sky from a little above the horizon and less of it than a flat mirror (waterSeenCos, waterSeenMirror in water_waves.glsl, used by the terrain and by the reflections pass alike). From a block or two above the water, far water mirrored almost only the whitish horizon. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Xzo7zbTpyU7a3y3XQNbw1n --- .../etna/shaders/include/terrain_water.glsl | 14 ++++++++----- .../etna/shaders/include/water_waves.glsl | 20 +++++++++++++++++++ .../assets/etna/shaders/post/water_ssr.fsh | 4 ++-- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/main/resources/assets/etna/shaders/include/terrain_water.glsl b/src/main/resources/assets/etna/shaders/include/terrain_water.glsl index 485f915..a0ab93c 100644 --- a/src/main/resources/assets/etna/shaders/include/terrain_water.glsl +++ b/src/main/resources/assets/etna/shaders/include/terrain_water.glsl @@ -587,8 +587,12 @@ float waterDepthBelow(out float trust) { // the bottom seen through it, and on murky or deep water a soft shadow in the water itself. Before 0.8.1 // the surface took the sharp shadow of its own point, a second copy of every shadow next to the one on // the bottom (shifted from it by the depth, the more the lower the sun), and the two crossed each other out -// in strange shapes. Four points down the view's way into the water, each at an equal share of the light -// scattered back above the bottom, each the bilinear blend of four texels (shadowSoftAt). A point counts +// in strange shapes. Four points down the line of sight into the water, each at an equal share of the light +// scattered back above the bottom, each the bilinear blend of four texels (shadowSoftAt). The line of sight +// goes straight on, unbent: the bottom shows through the water unbent too (the blend just lays the water over +// it), so the shade in the water lands on the shadow seen on the bottom. Up to 0.9.0 it followed the +// refracted ray, which runs steeper, and the two shadows sat side by side: shaded water over a lit bottom +// looked colourless and see-through, lit water over a shaded bottom like a patch of colour. A point counts // as shaded only by what stands above the water's surface on its way to the sun: it is looked up where // that way crosses the surface (the same texels of the map, a shallower depth), so a point that ends up // inside a bottom rising ahead of the view (a beach stepping up out of a lake) doesn't take the bottom over @@ -602,7 +606,7 @@ float waterBodyShadow(vec3 normal) { if (trust <= 0.0) { return shadowVisibility(normal, false); } - vec3 down = refract(normalize(etnaViewPos), vec3(0.0, 1.0, 0.0), 1.0 / WATER_IOR); + vec3 down = normalize(etnaViewPos); float cosDown = max(-down.y, 0.1); // Light scattered back from z blocks down went z / sin(sun) blocks through the water on its way down // and comes z / cos(view) blocks back up: green's extinction over both, per block of depth. @@ -981,8 +985,8 @@ vec4 shadeWater(vec4 color, vec3 normal) { // would only shimmer. vec3 n = normalize(vec3(-waves.seen.x, 1.0, -waves.seen.y)); n.y *= sign(normal.y); - float fresnel = waterFresnel(dot(toEye, n)); - vec3 skyReflection = etnaWaterSky(reflect(-toEye, n)) * open; + float fresnel = waterFresnel(waterSeenCos(dot(toEye, n), etnaPixelFootprint)); + vec3 skyReflection = etnaWaterSky(waterSeenMirror(reflect(-toEye, n), etnaPixelFootprint)) * open; float sun = sunGlint(waves, toEye, h, light) + waterGlitter(p, t, etnaPixelFootprint, waves.seen, target, light); #ifdef ETNA_SHADOWS // No glint and no glitter in the shade of trees and cliffs. diff --git a/src/main/resources/assets/etna/shaders/include/water_waves.glsl b/src/main/resources/assets/etna/shaders/include/water_waves.glsl index c4ddfdd..5fee4fd 100644 --- a/src/main/resources/assets/etna/shaders/include/water_waves.glsl +++ b/src/main/resources/assets/etna/shaders/include/water_waves.glsl @@ -71,6 +71,26 @@ vec2 swellSeen(vec2 p, float t, float footprint) { return slope; } +// Far water seen nearly edge on. The waves a pixel no longer resolves (waveSeen) still tilt the facets it +// sees towards the eye, by about HIDDEN_TILT (the swell's and the ripples' slopes), which only matters when +// the view is about as flat as that: those facets mirror sky from above the horizon, and less of it than a +// flat mirror would. Without them, water far away seen from a block or two above it mirrored almost only +// the whitish horizon and looked white. How far the facets tilt, for a pixel footprint blocks wide. +const float HIDDEN_TILT = 0.08; +float waterHiddenTilt(float footprint) { + return HIDDEN_TILT * (1.0 - waveSeen(length(SWELL_WAVES[0].xy), footprint)); +} +// The cosine between the view and the facets the pixel sees, from cosView, the one to the resolved surface. +float waterSeenCos(float cosView, float footprint) { + float tilt = waterHiddenTilt(footprint); + return sqrt(cosView * cosView + tilt * tilt); +} +// Where those facets mirror the sky from, for `mirrored`, the reflection off the resolved surface. +vec3 waterSeenMirror(vec3 mirrored, float footprint) { + float lift = 2.0 * waterHiddenTilt(footprint); + float up = max(mirrored.y, 0.0); + return normalize(vec3(mirrored.x, sqrt(up * up + lift * lift), mirrored.z)); +} // Schlick's Fresnel for water seen from the air, at the cosine between the view and the surface's normal: // the share of the light the surface mirrors (4 % straight down, all of it at grazing angles). float waterFresnel(float cosView) { diff --git a/src/main/resources/assets/etna/shaders/post/water_ssr.fsh b/src/main/resources/assets/etna/shaders/post/water_ssr.fsh index 829d597..01a44e9 100644 --- a/src/main/resources/assets/etna/shaders/post/water_ssr.fsh +++ b/src/main/resources/assets/etna/shaders/post/water_ssr.fsh @@ -275,9 +275,9 @@ void main() { vec3 toEye = -position / dist; // The sky's reflection the water drew, and how strong, came off the full swell; the ray follows a // calmer one. - vec3 dir = reflect(-toEye, n); + vec3 dir = waterSeenMirror(reflect(-toEye, n), footprint); vec3 ray = reflect(-toEye, normalize(vec3(-slope.x * TRACE_WAVES, 1.0, -slope.y * TRACE_WAVES))); - float mirror = waterFresnel(dot(toEye, n)) * WaterSsr.x; + float mirror = waterFresnel(waterSeenCos(dot(toEye, n), footprint)) * WaterSsr.x; // The haze and the render-distance fog the water shader's colour went through (aerialHaze in // terrain_dark.glsl, calculateFinalColor in terrain.fsh), for the light the reflection adds or takes away. float openSky = Air.w > 0.5 ? 1.0 - Shafts.z : 0.0;