Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src/main/resources/gg/grounds/gui/pack/text.vsh
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,16 @@ void main() {
}

// ProjMat is the GUI's orthographic matrix: m00 = 2/width, m11 = -2/height. The window
// stores the ceiling of framebuffer/guiScale, so re-apply it, minus a hair for the round
// stores the ceiling of framebuffer/guiScale, so re-apply it, minus a nudge for the round
// trip through float.
vec2 screen = ceil(2.0 / vec2(ProjMat[0][0], -ProjMat[1][1]) - 0.001);
//
// Half, because the bound is two-sided and half is the middle of it. The round trip can
// come back high, and anything less than the error ceils a whole pixel past; it can come
// back low, and anything within one of a pixel of 1.0 swallows a real one. Measured, the
// error peaks at 4.9e-4 around width 5837 and grows with the width — so 0.001, which this
// used to be, held a factor of two and would have failed on a display twice as wide. Half
// holds a factor of a thousand and costs nothing.
vec2 screen = ceil(2.0 / vec2(ProjMat[0][0], -ProjMat[1][1]) - 0.5);

// The server cannot know where the window lands, so it sends the sprite's top-left corner
// as an offset from the screen's centre and the shader supplies the centre.
Expand Down
104 changes: 104 additions & 0 deletions src/test/kotlin/gg/grounds/gui/theme/ScreenGeometryTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package gg.grounds.gui.theme

import kotlin.math.ceil
import kotlin.math.floor
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

/**
* The shader's arithmetic, checked without a GPU.
*
* Every marker's position is `floor(screen * 0.5) + offset`, where `screen` is recovered from the
* projection matrix rather than known. If that recovery is wrong by one pixel at some GUI scale,
* every themed screen is wrong by one pixel at that scale — and nobody plays at every scale, so it
* would be found by a player rather than by us.
*
* The recovery is float arithmetic, which is the one part of a shader that is the same on the CPU:
* GLSL's `highp float` and the JVM's `Float` are both IEEE binary32. So the formula can be replayed
* here over every size the client can produce.
*
* The epsilon is read out of the shipped shader rather than repeated, so a change there cannot pass
* this test by being invisible to it.
*/
class ScreenGeometryTest {
private val shader: String =
checkNotNull(javaClass.getResourceAsStream("/gg/grounds/gui/pack/text.vsh"))
.bufferedReader()
.use { it.readText() }

/** The `- 0.001` in `ceil(2.0 / ... - 0.001)`, taken from the shader itself. */
private val epsilon: Float =
Regex("""ceil\(2\.0 / vec2\(ProjMat\[0]\[0], -ProjMat\[1]\[1]\) - ([0-9.]+)\)""")
.find(shader)
?.groupValues
?.get(1)
?.toFloat()
?: error("the shader no longer recovers the screen size the way this test replays")

/** What the client puts in the matrix: an orthographic projection over the scaled GUI. */
private fun projection(scaledWidth: Int): Float = 2.0f / scaledWidth.toFloat()

/** What the shader gets back out of it. */
private fun recovered(scaledWidth: Int): Int =
ceil(2.0f / projection(scaledWidth) - epsilon).toInt()

@Test
fun `the screen size survives the round trip through the matrix`() {
// Every width the client can hand out: guiScaledWidth is ceil(framebuffer / guiScale), and
// a framebuffer runs from a small window to 8K. One pixel of error here is one pixel of
// error on every marker at that scale.
val wrong = (1..8192).filter { recovered(it) != it }
assertEquals(emptyList(), wrong, "widths the shader would misread")
}

@Test
fun `the nudge sits inside a two-sided bound, with room on both sides`() {
// Both directions matter, and that is what makes the value non-obvious. A round trip that
// comes back high needs the nudge to exceed the error, or the ceiling jumps a whole pixel.
// One that comes back low needs the nudge to stay a pixel clear of 1.0, or a real width is
// rounded away. So the safe range is [error, 1 - error] and the middle of it is the value
// that survives the widest displays — the first version used 0.001, which was legal but sat
// against the floor of that range.
val worst =
(1..8192).maxOf { width -> Math.abs(2.0f / projection(width) - width.toFloat()) }
assertTrue(worst < 0.01f, "round-trip error grew unexpectedly: $worst")
assertTrue(epsilon > worst, "nudge $epsilon must exceed the $worst error it absorbs")
assertTrue(epsilon < 1f - worst, "nudge $epsilon must stay clear of swallowing a pixel")

// And it should not merely be legal. Sitting near either end means the first wider display
// breaks it, which is a bug nobody can reproduce without that display.
val headroom = minOf(epsilon - worst, 1f - worst - epsilon)
assertTrue(
headroom > 0.4f,
"only $headroom of margin; the nudge is not centred in its range",
)
}

@Test
fun `a marker lands where the client puts the window, at both parities`() {
// The client centres a container with `leftPos = (width - imageWidth) / 2`, truncating.
// The shader computes `floor(screen / 2) + x - imageWidth / 2`. Those agree for even and
// odd screen widths, and the odd case is the one nobody checks by hand.
listOf(1920, 1921, 854, 855, 320, 321).forEach { screen ->
listOf(CONTAINER_WIDTH, 276).forEach { imageWidth ->
val clientLeft = (screen - imageWidth) / 2
val shaderOrigin = floor(screen / 2.0).toInt() + (0 - imageWidth / 2)
assertEquals(clientLeft, shaderOrigin, "screen $screen, window $imageWidth")
}
}
}

@Test
fun `a window wider than the screen still agrees, rather than drifting apart`() {
// A merchant window is 276 wide and the smallest GUI the client allows is 320, so this is
// reachable at scale 1 on a small window with a wide screen type.
listOf(280 to 276, 300 to 276).forEach { (screen, imageWidth) ->
assertEquals(
(screen - imageWidth) / 2,
floor(screen / 2.0).toInt() - imageWidth / 2,
"screen $screen, window $imageWidth",
)
}
}
}