Fractional display scaling leads to blurry rendering on browsers
Ebitengine Version
v2.7.0
Go Version (go version)
go1.21.1
What steps will reproduce the problem?
Running the following program will always result in non-blurry results on desktop, but it's inconsistent on browsers (except if the application is fullscreen) whenever fractional display scaling is being used. Some window sizes will lead to fractional values on the returned layout dimensions.
For context, the program uses LayoutF and displays the relevant parameters related to high-resolution rendering (example visual output after the code):
package main
import "fmt"
import "image/color"
import "math"
import "github.com/hajimehoshi/ebiten/v2"
import "github.com/hajimehoshi/ebiten/v2/inpututil"
import "github.com/hajimehoshi/ebiten/v2/text/v2"
import "github.com/hajimehoshi/bitmapfont/v3"
var LayoutNormFunctionNames []string = []string{"None", "Ceil", "Floor"}
var LayoutNormFunctions []func(float64) float64 = []func(float64) float64{
func(x float64) float64 { return x },
func(x float64) float64 { return math.Ceil(x) },
func(x float64) float64 { return math.Floor(x) },
}
type Game struct {
fontFace text.Face
lastLogicalWindowWidth float64
lastLogicalWindowHeight float64
lastDeviceScaleFactor float64
lastLayoutWidthReturn float64
lastLayoutHeightReturn float64
lastDrawFinalScreenWidth int
lastDrawFinalScreenHeight int
lastDrawFinalScreenGeoM ebiten.GeoM
useDrawFinalScreenGeoM bool
layoutNormFuncIndex int
}
func (self *Game) LayoutF(logicWinWidth, logicWinHeight float64) (float64, float64) {
scale := ebiten.DeviceScaleFactor()
self.lastDeviceScaleFactor = scale
self.lastLogicalWindowWidth = logicWinWidth
self.lastLogicalWindowHeight = logicWinHeight
self.lastLayoutWidthReturn = LayoutNormFunctions[self.layoutNormFuncIndex](logicWinWidth*scale)
self.lastLayoutHeightReturn = LayoutNormFunctions[self.layoutNormFuncIndex](logicWinHeight*scale)
return self.lastLayoutWidthReturn, self.lastLayoutHeightReturn
}
func (_ *Game) Layout(_, _ int) (int, int) {
panic("ebitengine version must support LayoutF()")
}
func (self *Game) DrawFinalScreen(screen ebiten.FinalScreen, offscreen *ebiten.Image, geom ebiten.GeoM) {
// cyan fill to we can see it if the screen is not fully filled by the offscreen
screen.Fill(color.RGBA{0, 255, 255, 255})
bounds := screen.Bounds()
self.lastDrawFinalScreenWidth = bounds.Dx()
self.lastDrawFinalScreenHeight = bounds.Dy()
// memorize geom and apply it depending on the configuration
self.lastDrawFinalScreenGeoM = geom
var opts ebiten.DrawImageOptions
if self.useDrawFinalScreenGeoM {
opts.GeoM = geom
}
screen.DrawImage(offscreen, &opts)
}
func (self *Game) Update() error {
if inpututil.IsKeyJustPressed(ebiten.KeyF) {
ebiten.SetFullscreen(!ebiten.IsFullscreen())
} else if inpututil.IsKeyJustPressed(ebiten.KeyG) {
self.useDrawFinalScreenGeoM = !self.useDrawFinalScreenGeoM
} else if inpututil.IsKeyJustPressed(ebiten.KeyL) {
self.layoutNormFuncIndex += 1
if self.layoutNormFuncIndex >= len(LayoutNormFunctions) {
self.layoutNormFuncIndex = 0
}
}
return nil
}
func (self *Game) Draw(screen *ebiten.Image) {
// dark background
screen.Fill(color.RGBA{0, 0, 0, 255})
// get screen metrics
bounds := screen.Bounds()
width, height := bounds.Dx(), bounds.Dy()
shortSide := min(width, height)
shortSideFract := shortSide/36
// collect all info
info := fmt.Sprintf(
"[F] Fullscreen: %t\n[L] LayoutNormFunc: %s\n[G] Using DrawFinalScreen() GeoM: %t\n\n" +
"Device scale factor: %.3f\n" +
"Layout logical window size: (%.3fx%.3f)\nLayout return dimensions: (%.3fx%.3f)\n" +
"Draw screen size: (%dx%d)\nDrawFinalScreen() screen size: (%dx%d)\n" +
"DrawFinalScreen() GeoM:\n [%.6f, %.6f, %.6f]\n [%.6f, %.6f, %.6f]",
ebiten.IsFullscreen(), LayoutNormFunctionNames[self.layoutNormFuncIndex],
self.useDrawFinalScreenGeoM, self.lastDeviceScaleFactor,
self.lastLogicalWindowWidth, self.lastLogicalWindowHeight,
self.lastLayoutWidthReturn, self.lastLayoutHeightReturn,
width, height, self.lastDrawFinalScreenWidth, self.lastDrawFinalScreenHeight,
self.lastDrawFinalScreenGeoM.Element(0, 0),
self.lastDrawFinalScreenGeoM.Element(0, 1),
self.lastDrawFinalScreenGeoM.Element(0, 2),
self.lastDrawFinalScreenGeoM.Element(1, 0),
self.lastDrawFinalScreenGeoM.Element(1, 1),
self.lastDrawFinalScreenGeoM.Element(1, 2),
)
// draw info
var opts text.DrawOptions
const Scale = 2
opts.DrawImageOptions.GeoM.Scale(Scale, Scale)
opts.DrawImageOptions.GeoM.Translate(float64(shortSideFract), float64(shortSideFract))
opts.LayoutOptions.LineSpacing = 8*Scale
text.Draw(screen, info, self.fontFace, &opts)
}
func main() {
ebiten.SetWindowTitle("blurrybrowser")
ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)
err := ebiten.RunGame(&Game{
useDrawFinalScreenGeoM: true,
fontFace: text.NewGoXFace(bitmapfont.Face),
})
if err != nil { panic(err) }
}
Despite the given program, notice that every Ebitengine application/game can suffer this problem. The given program is simply useful to illustrate the problem while helping debug the critical related variables. In different programs the blurriness will be more or less obvious.
What is the expected result?
Consistently sharp rendering on the browser, in the same way that already happens on desktop.
As of now, it's not possible to guarantee high quality rendering on the browser.
What happens instead?
Blurry rendering on the browser.
Anything else you feel useful to add?
Tested on Windows 11, with different fractional scalings (1.25, 1.5, 1.75), on Firefox and Chrome. More testing might be necessary on Linux/Mac desktops too.
Given the attached program:
- On desktop, the "Layout logical window size" sometimes takes fractional values, but when multiplied by the scaling, they always result in whole numbers. This is the key difference with browsers, where this doesn't always happen. Resizing the browser window slightly will result in decimal values for the "Layout return dimensions" every now and then, making the final rendering blurry.
- The program allows selecting different rounding methods for the layout return dimensions, and allows enabling/disabling the use of the
DrawFinalScreen()GeoMat runtime. No combination of these help get rid of the blurriness. - Sometimes noticing the blurriness can be a bit difficult. The easiest way to see it is finding a window size in which the layout return dimensions are decimal, and then switch in/out fullscreen mode with
F.
Why did you remove the platform list...? Did this happen only on Windows browsers?
Also, could we have more minimized test case?
About where this happens:
Tested on Windows 11, with different fractional scalings (1.25, 1.5, 1.75), on Firefox and Chrome. More testing might be necessary on Linux/Mac desktops too.
About minimal example:
[...] notice that every Ebitengine application/game can suffer this problem.
E.g. (but this is not very helpful to debug / understand the behavior):
package main
import "github.com/hajimehoshi/ebiten/v2"
import "github.com/hajimehoshi/ebiten/v2/text/v2"
import "github.com/hajimehoshi/bitmapfont/v3"
type Game struct {
fontFace text.Face
}
func (self *Game) LayoutF(logicWinWidth, logicWinHeight float64) (float64, float64) {
scale := ebiten.DeviceScaleFactor()
return logicWinWidth*scale, logicWinHeight*scale
}
func (_ *Game) Layout(_, _ int) (int, int) {
panic("ebitengine version must support LayoutF()")
}
func (self *Game) Update() error { return nil }
func (self *Game) Draw(screen *ebiten.Image) {
var opts text.DrawOptions
opts.DrawImageOptions.GeoM.Scale(2, 2)
opts.DrawImageOptions.GeoM.Translate(8, 8)
text.Draw(screen, "Minimal example", self.fontFace, &opts)
}
func main() {
ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)
err := ebiten.RunGame(&Game{ fontFace: text.NewGoXFace(bitmapfont.Face) })
if err != nil { panic(err) }
}
I'll try
Why did you remove the platform list...?
Is there a reason? I'm just curious
I didn't really know what to put, because Windows is technically not a problem as a desktop platform, but I don't know if the browser problems happen only on Windows or not (I assume not). And I hadn't tested other desktops to see if they were also affected. So I didn't know if it was a combination of platforms, or what. In fact, I don't think the browsers are doing anything "wrong" per se either; the bug might be on Ebitengine's handling of floating returns on layout. I honestly don't know what's the platform, so I just explained it with words. In theory, it's a "with certain display scale factors and floating point arguments in layout, the result will not look sharp", in general, not platform-specific. It just happens that on Windows, the decimal window sizes given don't end up being problematic.
In fact, maybe LayoutF should document a bit more what happens with decimal returned values, the whole situation is unclear to me.
I didn't really know what to put
In this case please leave the list as it is next time, thanks
I think checking Windows and Browsers would be fine for this case.
Quick note, but I've observed that when logicalSize*scale ends in .75, blurriness is higher than with .25 and .5. Ebitengine is internally using the ceil function. Maybe we could try the floor function instead? Maybe browsers are truncating directly? This might also explain why no matter what I try to do on DrawFinalScreen() or what clamping function I use on Layout(), nothing helps (the browser might receive a request for a canvas size 1 pixel bigger than expected, and ends up doing that 1 pixel scaling compensation internally, leading to blurriness).
I was testing a bit more today, trying to clamp instead of ceiling and so on, and had no success, but noticed this warning on firefox:
WebGL warning: drawElementsInstanced: Drawing to a destination rect smaller than the viewport rect. (This warning will only be given once)
I don't know how to get the gl context that ebitengine is using to gets its dimensions right now, but might be worth checking out (unless this warning is issued at the start of the rendering during setup or something). I also tried to ceil the ui context screenWidth and screenHeight on layoutGame, but that didn't help the situation either. We should definitely debug the actual gl context size next to see whether there's a mismatch there (but neither floor nor ceil are solving the issue even when tweaked directly on the ebitengine internals, so I'm a bit at a loss here). I also tested on Chrome to make sure the behavior is the same across browsers, and it is; the only difference seems to be that Chrome isn't giving any warning.
Minor note: while it was filed later, #2978 should be addressed before this, as it might help or change something. I will test again when that's resolved.