Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fractional display scaling leads to blurry rendering on browsers #2958

Open
tinne26 opened this issue Apr 8, 2024 · 9 comments
Open

Fractional display scaling leads to blurry rendering on browsers #2958

tinne26 opened this issue Apr 8, 2024 · 9 comments

Comments

@tinne26
Copy link

tinne26 commented Apr 8, 2024

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) }
}

screenshot

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() GeoM at 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.
@tinne26 tinne26 added the bug label Apr 8, 2024
@hajimehoshi
Copy link
Owner

hajimehoshi commented Apr 9, 2024

Why did you remove the platform list...? Did this happen only on Windows browsers?

@hajimehoshi
Copy link
Owner

Also, could we have more minimized test case?

@tinne26
Copy link
Author

tinne26 commented Apr 9, 2024

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) }
}

@hajimehoshi
Copy link
Owner

I'll try

Why did you remove the platform list...?

Is there a reason? I'm just curious

@tinne26
Copy link
Author

tinne26 commented Apr 9, 2024

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.

@hajimehoshi
Copy link
Owner

hajimehoshi commented Apr 9, 2024

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.

@tinne26
Copy link
Author

tinne26 commented Apr 9, 2024

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).

@tinne26
Copy link
Author

tinne26 commented Apr 27, 2024

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.

@tinne26
Copy link
Author

tinne26 commented May 18, 2024

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants