From e6d4059c383f8dc8534bb26856b7cb27bbb1e3a5 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sun, 2 Aug 2026 00:53:38 -0400 Subject: [PATCH] fix water again on android --- lib/VoxelScene.lua | 63 ++++++++++++++++++++++------------- lib/VoxelState.lua | 27 +++++++++++++-- lib/Water.lua | 26 ++++++++++++--- tests/dramatic_shape_test.lua | 8 +++-- 4 files changed, 90 insertions(+), 34 deletions(-) diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index e06036e..529f367 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -583,7 +583,7 @@ end -- The overworld's alone: the staged battle draws its water plain, always -- -- its placed camera reads this pass wrong, and a stage set wants painted -- water anyway (see BattleScene, where the choice is argued). --- ------- and why the flat draw happens FIRST, every time +-- ------- and why the flat draw happens FIRST while the world is curved -- -- The reflective pass writes no depth -- it cannot, the depth canvas is -- detached for the length of it so the shader can READ it -- and it does its @@ -592,31 +592,39 @@ end -- one: WATER IN FRONT OF WATER. Nothing puts water in the depth buffer, so -- no lake can hide another, and the pass simply paints them in mesh order. -- --- On flat water that never mattered, because every surface lies in the one --- plane at its own recessed height and a farther sheet always lands farther --- down the screen. THE WORLD CURVE ENDS THAT. The bend drops the world by --- the square of its distance, so the far side of the map swings down and --- back up into the near field of view -- and a sheet of sea a hundred and --- fifty tiles away, drawn later in the same mesh, paints straight over the --- pond at the player's feet. That is the tall grass and the water "from the --- other side of the map": not a reflection of them, THEM, rasterised on top --- of the water in front of you. +-- On a flat world that never matters: every surface lies in the one plane +-- at its own recessed height, and a farther sheet always lands farther down +-- the screen. THE WORLD CURVE ENDS THAT. The bend drops the world by the +-- square of its distance, so the far side of the map swings down and back +-- up into the near field of view -- and a sheet of sea a hundred and fifty +-- tiles away, drawn later in the same mesh, paints straight over the pond +-- at the player's feet. Not a reflection of the far shore: the far shore +-- itself, rasterised on top of the water in front of you. -- --- So the meshes go down flat first, through the ordinary scene shader with --- depth writes on, and the reflective pass draws over the top of what --- survived. Three things fall out of it and all three are wanted: the depth --- buffer now holds the water surface, so the pass's own test throws the far --- sheet away; the reflection COPY holds it too, so a ray grazing another --- part of the lake reads water rather than the void behind it; and the flat --- draw is the same fallback this function already ended with, so a frame --- that cannot run the reflective pass is unchanged. +-- So WHILE THE CURVE IS ON, the meshes go down flat first, through the +-- ordinary scene shader with depth writes on, and the reflective pass draws +-- over the top of what survived: the depth buffer now holds the water +-- surface, so the pass's own test throws the far sheet away, and the +-- reflection COPY holds it too, so a ray grazing another part of the lake +-- reads water rather than the void behind it. -- --- It costs one more rasterisation of the water meshes. Water is a small --- share of a frame's triangles and this is the cheapest shader in the mode. +-- With the curve OFF the prepass is not just unnecessary, it is a LIABILITY, +-- and it stays off -- the reflective pass tests only against terrain, as it +-- always did. Painting the surface into the depth texture turns the pass's +-- test into a comparison of the surface against ITSELF, which asks the two +-- rasterisations to agree to within interpolation error -- and on mobile +-- GPUs they don't reliably (that fight is what put the Android port back on +-- flat water). Confined to the curve there is no regression to reach: the +-- flat world never had the far-shore bug in the first place. function VoxelScene.drawWater(draws, cast) - for _, d in ipairs(draws) do - Voxel3D.draw(d[1], d[2], d[3]) + -- prepass only under the bend; see the header + local curved = (Voxel3D.curveK or 0) > 0 + if curved then + for _, d in ipairs(draws) do + Voxel3D.draw(d[1], d[2], d[3]) + end end + local plain = not curved if Water.enabled() and Voxel3D.depthReadable() then local mirror, depth = Voxel3D.beginWater(cast) local w, h = Voxel3D.size() @@ -634,15 +642,22 @@ function VoxelScene.drawWater(draws, cast) Water.draw(d[1], d[2], d[3]) end Water.finish() + plain = false end -- Unconditionally, and OUTSIDE the success branch: beginWater unbinds -- the shader and the depth mode BEFORE it can discover it cannot go on, -- so a frame that bails halfway through has to be put back together -- exactly like one that succeeded -- otherwise every pass after it runs - -- with no shader and no depth test. (What a bail leaves on screen is the - -- flat draw above, which is where this function used to end anyway.) + -- with no shader and no depth test. Voxel3D.endWater() end + -- the fallback flat draw -- unless the curve's prepass already put the + -- same meshes down, in which case a bailed frame is already whole + if plain then + for _, d in ipairs(draws) do + Voxel3D.draw(d[1], d[2], d[3]) + end + end end -- A stamp of everything the sun pass depends on. Nothing in it moving diff --git a/lib/VoxelState.lua b/lib/VoxelState.lua index efad2ec..e19b314 100644 --- a/lib/VoxelState.lua +++ b/lib/VoxelState.lua @@ -32,8 +32,17 @@ local Voxel = {} -- Its ANGLE is 35 degrees, the same as the rung of that name. The duplicate -- in the table is deliberate: the ladder is a list of what each rung LOOKS -- like, and two rungs may look the same while meaning different things. -Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75 } -Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75" } +-- +-- 1ST is the other rung that is more than an angle: the camera steps off its +-- orbit entirely and stands in the player's own eyes (lib/FirstPerson.lua), +-- with free look and free movement. Its ANGLE entry is 75 -- the orbit rung +-- it hands over from -- because the tween in and out of first person starts +-- from whatever the orbit shows, and the lowest rung is the one a dive into +-- a head should start from. Everything angle-derived (the sky's fade, the +-- billboard lean the blend eases away) reads that 75 while the first-person +-- rig owns the actual camera. +Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75, 75 } +Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75", "1ST" } Voxel.MAX_LEVEL = #Voxel.ANGLES_DEG - 1 -- the rung FULL sits on, so nothing has to hunt for it by label @@ -43,6 +52,13 @@ function Voxel.isFull(level) return (level or Voxel.level) == Voxel.FULL_LEVEL end +-- the rung the first-person camera sits on, likewise +Voxel.FP_LEVEL = 6 + +function Voxel.isFirstPerson(level) + return (level or Voxel.level) == Voxel.FP_LEVEL +end + -- ------- what the hotkey walks -- -- The ANGLE rungs only, with FULL left out. The key is a display-mode @@ -51,7 +67,12 @@ end -- mid-walk, would silently turn the blur to maximum and flatten the horizon -- with no indication that a keypress had done so. FULL stays on the OPTIONS -- row, which is where a preset that changes other rows belongs. -Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5 } -- OFF, 15, 35, 50, 75 +-- +-- 1ST is on the path: it changes the camera and only the camera, which is +-- exactly what the key promises -- and the key is also the way back OUT of +-- first person on a keyboard, where the mouse is captured and the OPTIONS +-- menu is a trip. +Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5, 6 } -- OFF, 15, 35, 50, 75, 1ST -- The rung a press moves to from `level`. -- diff --git a/lib/Water.lua b/lib/Water.lua index 3bfcf4e..45ecd26 100644 --- a/lib/Water.lua +++ b/lib/Water.lua @@ -960,11 +960,29 @@ vec4 effect(mediump vec4 color, Image tex, mediump vec2 tc, mediump vec2 sc) { // The buffer now holds THIS SURFACE too (VoxelScene draws the water flat // before the pass that reflects it), which is what makes one lake able to // hide another -- and it means every fragment here is testing against its - // own depth. The two draws reach it through different shader programs, so - // ask for a hair of slack rather than bit-equality: far less than the gap - // to anything genuinely in front, far more than two compilers disagree by. + // own depth. That raises the bar on the fragment's own z: gl_FragCoord is + // allowed to be MEDIUMP on GLES (and is, on Adreno), and fp16 near the far + // end of the range steps by about half a thousandth -- which the old test + // against the terrain far behind the surface never felt, and a comparison + // of the surface against itself loses outright. Every fragment failed, the + // pass discarded the whole lake, and Android showed the flat draw + // underneath. So the depth is recomputed HERE, in highp, from the same + // vBent and vp the vertex stage used -- full precision on every driver. + // + // The slack is sized to what remains after that, which is not rounding: + // the buffer holds depth interpolated LINEARLY IN SCREEN SPACE, while the + // recomputation projects the perspective-interpolated vBent -- the exact + // answer. The two agree at the vertices and drift apart across a quad's + // interior, by more the bigger the quad stands on screen; on a phone + // (fit scale 6, water quads hundreds of pixels tall) the drift crosses + // 1e-5 mid-quad, which discarded the middle of every tile row and looked + // like flat water with reflective seams. Anything GENUINELY in front of a + // water pixel is whole world units nearer -- upward of 1e-3 in depth -- + // so 2e-4 clears the drift with room while still catching every occluder. vec2 uv = sc / love_ScreenSize.xy; - if (gl_FragCoord.z > Texel(depthTex, uv).r + 1e-5) discard; + vec4 selfC = vp * vec4(vBent, 1.0); + float selfZ = selfC.z / selfC.w * 0.5 + 0.5; + if (selfZ > Texel(depthTex, uv).r + 2e-4) discard; // THE COLUMN THIS FRAGMENT IS LOOKING AT. Every water pixel is a bar of // its own standing a whole number of pixels tall, and the ray decides diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index a832e15..703763a 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -1924,9 +1924,11 @@ T.check(plain:find("sc / love_ScreenSize.xy", 1, true) ~= nil, -- the world curve drops the far side of the map into the near field of view, -- and a sea a hundred and fifty tiles away came out rasterised on top of the -- pond at the player's feet, tall grass and all. -T.check(plain:find("Texel(depthTex, uv).r + 1e-5", 1, true) ~= nil, - "with a hair of slack, because every water fragment is now testing " - .. "against its own depth through a second shader program") +T.check(plain:find("vec4 selfC = vp * vec4(vBent, 1.0)", 1, true) ~= nil + and plain:find("Texel(depthTex, uv).r + 2e-4", 1, true) ~= nil, + "testing a HIGHP recomputed depth (gl_FragCoord.z is mediump on mobile " + .. "GLES -- fp16 loses a self-comparison outright) with slack covering " + .. "the buffer's screen-linear interpolation drift across big quads") T.check(VoxelScene.drawWater ~= nil, "and the flat draw that puts it there") -- ------- the lift itself