Skip to content

Etna 0.8.1: solid water reflections, thin shoreline foam, shadows that soften with distance - #3

Draft
Atomsk-debug wants to merge 14 commits into
mainfrom
claude/etna-081
Draft

Atomsk-debug wants to merge 14 commits into
mainfrom
claude/etna-081

Conversation

@Atomsk-debug

@Atomsk-debug Atomsk-debug commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

Before: in 0.8.0, the shore, trees and hills mirrored in lakes and rivers break up into hundreds of scattered dark dots instead of keeping their shapes. Shoreline foam is a thick, glowing white band. Shallow water turns milky, and thin sheets of water flowing down a bank are covered in blotches. Shadows keep the same sharp edge however far they reach from what casts them, so long low-sun shadows look like hard black strips. Past the shadow casters' range, they stop at a hard line. In the first 0.8.1 test build, reflections and far shadows shimmered, and a shadow falling on water showed twice, the two copies cancelling each other. While walking, the dappled shade under trees and the shadow edges further out changed on almost every frame, and far tree shade twinkled even standing still.

After: reflections are solid shapes that wobble gently with the waves and stay steady from frame to frame. Foam is a thin, faint, bubbly line where still water meets the shore. Shallow water stays clear, and water flowing down a slope has no foam. Shadows are sharp where they touch their caster and blur as they stretch away from it, as in real life, without shimmering. At sunrise and sunset they are lighter. At the edge of the casters' range they fade out instead of ending. A shadow on water is one shadow: on clear shallow water it is the bottom's, on deep or murky water a soft shade inside the water. While walking, the shade under trees holds still and only changes slightly about twice a second, and the far end of the shadows stays at the same distance. Standing still, far tree shade is completely still. The shadow map warp and the water's Fresnel are Etna's own code now, not Photon's.

How:

  • post/water_ssr.fsh:
    • 24 fixed steps plus 6 halvings, with no per-pixel noise and one fixed pixel per 2x2 block.
    • A crossing counts as a hit only when the surface is no nearer than the ray was just before it. A ray that passes behind something in front of it doesn't count.
    • Misses are written with their distance and no colour change.
    • The ray follows a quarter of the swell's slope. The sky reflection that gets removed keeps the full swell.
    • Each ray starts from the point the jittered pixel shows, worked out without the jitter, and reads depth and colour where the jittered frame holds each point it wants.
    • With anti-aliasing on, each texel blends with its own history (30 % of the new frame, more while the camera moves). The history is reprojected through the water point's position and PrevViewProj. A history texel is only used where last frame held water at the same distance, allowing for how fast that distance changes across a few pixels on grazing far water.
  • tools/gen_post_effects.py, etna_world.json, etna_world_plain.json, PostChainMixin: two persistent half-size targets, water_ssr0/1, hold that history and are swapped together with the TAA history. The plain chain binds a placeholder it never reads.
  • include/water_reflection.glsl: the upsampler uses the same 16 texels every frame, and misses count. In 0.8.0 it skipped them, so one lone hit grew into a speck.
  • include/terrain_water.glsl:
    • The foam over shallow floors is gone. The line is narrower and fainter, broken by fine bubbles, and pale instead of bright white. It only appears on flat water.
    • fresnelDielectric is rewritten in the exact Cook-Torrance form.
    • New waterBodyShadow: the water's sun share is 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 point is looked up where its way to the sun crosses the surface, so only what stands above the water shades it, and a bottom rising ahead of the view doesn't. This replaces the surface's own sharp shadow, which crossed out the bottom's shifted one.
    • The thickness search has a fourth ring at 8 blocks. Where the shadow map shows no bottom at all, the depth model assumes 4 blocks of water instead of falling back to the plain, opaque look.
  • core/terrain.fsh: new etnaWaterLevel flag, taken from the face normal before it is snapped to an axis. Water faces take waterBodyShadow.
  • include/shadow_distortion.glsl: Etna's own warp, factor = 0.85 n + 0.15 with n = sqrt(1 - (1 - x^2)(1 - y^2)). It fills the whole map and keeps far texels two to five times finer towards the corners.
  • core/terrain.vsh: in the shadow pass, leaves and plants sway only near the camera (fading out from 20 to 44 blocks). Further out a map texel is wider than the sway, which only flipped leaf texels between lit and shaded every frame. The leaves still sway in the main pass, by less than their shadow lookup's offset and bias.
  • include/terrain_shadow.glsl:
    • Contact hardening, the PCSS idea reimplemented, now deterministic. The area-weighted box filter also reports how much it found blocked and how far the casters are. Six fixed bilinear reads search the ring beyond it.
    • The blur radius is the softness plus 0.025 x the casters' average distance along the light, capped at 1.2 blocks or 16 texels. Where everything read is blocked or everything is open, the box result is final. Otherwise it blends into a 16-point bilinear disc. Only discs wider than about 8 texels are turned, by more the wider they are, so near shadows never change from frame to frame.
    • New shadowSoftAt: a bilinear 2x2 test of any point, used by the water.
    • Shadows get up to 35% lighter when the sun is below about 20 degrees.
    • Receivers beyond the casters' chunk box fade to lit.
    • Points beyond the map's far depth plane count as lit. This also applies in the unfiltered test, leaves, and post/shafts.fsh.
    • New etnaMapOut: how far out a point lies, counted from the camera (shadowMat[3].xy) over EtnaShadowInfo.w, the share of the radius the map always covers round the camera. Every reader (filter, single and bilinear tests, leaf light, water, post/shafts.fsh) fades and stops by it, so where the shadows end doesn't move when the map's middle steps. They end about 3.5 blocks short of the shadow distance (the hold plus 2 blocks of slack). A point off the map itself also counts as past the edge (a map read a frame late behind a fast-flying camera).
  • EtnaShadows.java:
    • Caster culling allows for the blur's reach, both sideways and away from the light, in the view test's margins and the receiver floor, and its bounds follow the new warp.
    • New benchmarkFade, off by default: the benchmark can hold a sun-step fade at its middle, to measure the cost of reading both maps while the sun moves.
    • The map's middle no longer moves a texel at a time (most frames while walking). The warp is centred on the middle, so every move re-samples every texel at new points of the world: leaf holes and far edges came out different each time. followMiddle keeps each map's middle fixed in the world and steps it by 2 blocks (whole texels) once the camera is 1.5 blocks from it on an axis. Near the camera texels are at most 7% wider. The caster tests count from the middle (the square, the caster floor) or widen by the camera's lead (the columns, the view margins). EtnaShadowInfo.w (unread since the shafts became a post pass) now carries the reach.
  • CREDITS and README list the warp and the Fresnel as Etna's own. Version 0.8.1, CHANGELOG updated.

The dot analysis was cross-checked against the look thread's own patch: the calmer ray normal and the steady 16-texel upsample come from it.

Testing so far:

  • Reflections and shadows were checked offline with headless GL renders and a model of Etna's TAA. After anti-aliasing, shadow flicker is 35-65 % below the first test build, and below 0.8.0 with a low sun.
  • Caster culling was checked at FOV 70, 110 and 126: none of 80,000 casters was wrongly dropped.
  • The map's middle steps were checked offline on a swaying canopy of leaf blocks with holes, 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, and the frame the map steps changes no more than any frame did before. With the shadow sway faded out, a still camera sees no change at all from 32 to 150 blocks (0.003-0.004 before), and walking about 40% less.
  • A second review read the water-shadow and reflection-history code, and its two findings are fixed.
  • All shader variants pass tools/validate_shaders.py.
  • The jar builds on a Mac mini M4, where the game's post-chain format accepted the persistent, sized history targets.

The latest build has not been tested in game yet.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Xzo7zbTpyU7a3y3XQNbw1n


Generated by Claude Code

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xzo7zbTpyU7a3y3XQNbw1n
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xzo7zbTpyU7a3y3XQNbw1n
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xzo7zbTpyU7a3y3XQNbw1n
@Atomsk-debug Atomsk-debug changed the title Etna 0.8.1: solid water reflections, thin shoreline foam Etna 0.8.1: solid water reflections, thin shoreline foam, shadows that soften with distance Oct 10, 2026
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xzo7zbTpyU7a3y3XQNbw1n
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xzo7zbTpyU7a3y3XQNbw1n
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xzo7zbTpyU7a3y3XQNbw1n
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xzo7zbTpyU7a3y3XQNbw1n
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xzo7zbTpyU7a3y3XQNbw1n
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xzo7zbTpyU7a3y3XQNbw1n
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xzo7zbTpyU7a3y3XQNbw1n
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xzo7zbTpyU7a3y3XQNbw1n
…mera

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xzo7zbTpyU7a3y3XQNbw1n
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xzo7zbTpyU7a3y3XQNbw1n
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants