pythonarcade / arcade

Easy to use Python library for creating 2D arcade games.
http://arcade.academy
Other
1.69k stars 319 forks source link

Grids of SpriteSolidColor sometimes produce missing pixels at specific zoom levels #2107

Open MiCurry opened 3 months ago

MiCurry commented 3 months ago

Bug Report

Originally found by @alejcas.

A grid of SpriteSolidColors can produce missing pixels on some machines on some irrational zoom levels. These missing pixels appear as very small squares, but also as full lines depending on the zoom level. See the video below:

In this video, there are 25x25 squares (50 squares total) which all have a width and height of 32.

https://github.com/pythonarcade/arcade/assets/2590700/3ea0e1f0-21c5-4c3a-86db-583767d88c88

These missing pixels apparently only appear between the squares. In this example, the grid lines are set to same spacing as the squares.

https://github.com/pythonarcade/arcade/assets/2590700/2c23fe47-9af6-45a3-8e32-fed2390842c9

System Info

This apparently is machine dependent.

Happens on this Intel Mac:

Arcade 3.0.0.dev29
------------------
vendor: Intel Inc.
renderer: Intel Iris Pro OpenGL Engine
version: (4, 1)
python: 3.11.5 (main, Aug 24 2023, 15:23:14) [Clang 13.0.0 (clang-1300.0.29.30)]
platform: darwin
pyglet version: 2.1.dev2
PIL version: 10.2.0

Does not happen on this Windows machine:

Arcade 3.0.0.dev29
------------------
vendor: NVIDIA Corporation
renderer: NVIDIA GeForce GTX 1080/PCIe/SSE2
version: (3, 3)
python: 3.11.8 (tags/v3.11.8:db85d51, Feb  6 2024, 22:03:32) [MSC v.1937 64 bit (AMD64)]
platform: win32
pyglet version: 2.1.dev2
PIL version: 10.2.0

Steps to reproduce/example code:

The below program produces the missing pixels, though, it appears to machine dependent. You can use the minus and equal key to subtract and add 0.01 zoom. You can use comma and period to increase or decrease the width and height of the squares and grid. Use 'g' to turn on or off the grid lines.

from typing import Optional

import arcade
from arcade.shape_list import ShapeElementList, create_line
from pyglet.math import Vec2

from map_data import MAP

GRID_COLOR = arcade.color.Color(255, 255, 255)

class MyWindow(arcade.Window):

    def __init__(self):
        super().__init__(width=1280, height=768)

        # sprite list to hold the map sprites
        self.map: arcade.SpriteList = arcade.SpriteList(use_spatial_hash=True)
        self.zoom = 0.96
        self.draw_grids = False

        self.nx = 25
        self.ny = 25
        self.tile_size = 50

        self.create_squares(self.nx, self.ny, self.tile_size)
        self.create_grid(self.nx, self.ny, self.tile_size)

        camera_viewport = (0, 0, self.width, self.height)
        self.camera = arcade.camera.Camera2D(viewport=camera_viewport, zoom=self.zoom)

    def create_squares(self, nx, ny, tile_size):
        print("Recreating squares:", nx, ny, tile_size)
        red = arcade.color.Color(255, 0, 0)
        self.map = arcade.SpriteList(use_spatial_hash=True)
        for x in range(0, nx):
            for y in range(0, ny):
                tile = arcade.sprite.SpriteSolidColor(width=tile_size, height=tile_size,
                                                      color=red)
                tile.left = x * tile_size
                tile.bottom = y * tile_size
                self.map.append(tile)

    def create_grid(self, nx, ny, tile_size):
        print("Creating new grid:", nx, ny, tile_size)
        self.grid_lines = ShapeElementList()
        for x_pixels in range(0, (nx + 1) * tile_size, tile_size):
            shape = create_line(x_pixels, -10000, x_pixels, 10000, GRID_COLOR)
            self.grid_lines.append(shape)

        for y_pixels in range(0, (ny + 1) * tile_size, tile_size):
            shape = create_line(-10000, y_pixels, 10000, y_pixels, GRID_COLOR)
            self.grid_lines.append(shape)

    def on_draw(self):
        self.camera.use()
        self.clear(arcade.color.BLUE)
        self.map.draw(pixelated=True)
        if self.draw_grids:
            self.grid_lines.draw()

    def on_key_press(self, key, modifier):
        if key == arcade.key.MINUS:
            self.camera.zoom -= 0.01
            print("Zoom:", self.camera.zoom)
        elif key == arcade.key.EQUAL:
            self.camera.zoom += 0.01
            print("Zoom:", self.camera.zoom)
        elif key == arcade.key.KEY_1:
            self.camera.zoom = 1.0
            print("Zoom:", self.camera.zoom)
        elif key == arcade.key.G:
            self.draw_grids = not self.draw_grids
        elif key == arcade.key.COMMA:
            self.tile_size -= 1
            self.create_squares(self.nx, self.ny, self.tile_size)
            self.create_grid(self.nx, self.ny, self.tile_size)
        elif key == arcade.key.PERIOD:
            self.tile_size += 1
            self.create_squares(self.nx, self.ny, self.tile_size)
            self.create_grid(self.nx, self.ny, self.tile_size)

    def on_mouse_press(self, x: int, y: int, button: int, modifiers: int):
        print(x, y)

if __name__ == '__main__':
    window = MyWindow()
    window.run()
MiCurry commented 3 months ago

This is probably a precision or floating point issue. The way the missing pixels appear makes me think that perhaps it has to do with the inability to represent decimal in binary, but that's just a guess. Both the machines listed above are 32 bit. I'm not sure if it's the compiler, render, OS or monitor.

Others have suggested that perhaps it needs some padding like a traditional texture. Perhaps that is the case and something similar is needed for solid sprites?

einarf commented 3 months ago

I suspect this is float precision issue in the vertices itself.

We can add some crazy padding in the atlas to eliminate that issue (at least on my computer)

        self.map: arcade.SpriteList = arcade.SpriteList(
            use_spatial_hash=True,
            atlas=arcade.TextureAtlas((2048, 2048), border=1024)
        )

On descrete gpu I can see issue at zoom level 0.06999999999999926 or 0.07 when I round the value. I suspect it's probably slightly more noticeable on iGPUs.

One idea is to capture the emitted vertex data from the sprite shader and inspect it. We should also closely inspect projection and view matrix. Changing the way we calculate the vertices can also have an impact. Instead of using the center point and offset with the half size we can just calculate the corners applying the the width and the height.

We should also compare the instanced version of sprite shader. If that has the same issue there is something deeper.

einarf commented 3 months ago

In latest master you also need this

        camera_viewport = arcade.Rect(0, self.width, 0, self.height, self.width, self.height, 0, 0)
        self.camera = arcade.camera.Camera2D(viewport=camera_viewport, zoom=self.zoom)
alejcas commented 3 months ago

In my case it's happening with nvidia rtx 2060

alejcas commented 2 months ago

So, as commented in discord and as a reminder for anyone who may need it in the future:

First this problem is now much smaller with the recent changes in arcade (center pixel interpolation). But still they appear.

"Artifacts" can appear when applying small zoom levels to a camera

To avoid it:

1) Always set zoom levels to the power of 2 (2**-3, etc.) 2) Even doing so, artifacts may still appear if the zoom is small. To avoid this, create a custom atlas and set its border to a confortable number where (by trial and error) see that the artifacts disappear. 3) Note that changing the zoom level to an even smaller number will need for a bigger atlas border.

Atlas can be created like so:

# Default atlas border is 2.
my_atlas = arcade.DefaultTextureAtlas((512, 512), border=3, auto_resize=True)
# then just set the spritelist atlas to your custom atlas
my_sprite_list = arcade.SpriteList(atlas=my_atlas)
einarf commented 2 months ago

Tagging this to a doc issue. It's something we could potentially mention in the manual