From 98c48ce155cfedec87d4c3ec58f036a10efac0ca Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 22:01:31 +0000 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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 34805a1d556bf396f1dda2b0f5cbde06531b806d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 02:14:18 +0000 Subject: [PATCH 05/12] 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 06/12] 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 c90ccf38da9ff88439a024ada1b6af07c45ebeaa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 02:54:00 +0000 Subject: [PATCH 07/12] 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 10f13373d07e376b29ddaf5eeb6df6d820cd8be2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 03:02:04 +0000 Subject: [PATCH 08/12] 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 86371a9554f765968fcb271f50f310ff5f84b7d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 04:10:09 +0000 Subject: [PATCH 09/12] 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 10/12] 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 11/12] 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 98af66f567f70bd0158cff777209052917a0926f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 10:31:34 +0000 Subject: [PATCH 12/12] 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;