Jak poruszać kamerą w pełnych odstępach między pikselami?

13

Zasadniczo chcę powstrzymać kamerę przed poruszaniem się w subpikselach, ponieważ myślę, że prowadzi to do duszków, które choć nieznacznie zmieniają wymiary. (Czy istnieje na to lepszy termin?) Zauważ, że jest to gra pikselowa, w której chcę mieć wyraźną grafikę pikselową. Oto gif, który pokazuje problem:

wprowadź opis zdjęcia tutaj

Teraz próbowałem: Przesunąć kamerę, rzutować aktualną pozycję (więc współrzędne ekranu), a następnie zaokrąglić lub rzutować na int. Następnie przekonwertuj go z powrotem na współrzędne świata i użyj go jako nowej pozycji kamery. O ile wiem, powinno to zablokować kamerę do rzeczywistych współrzędnych ekranu, a nie ich ułamków.

Jednak z jakiegoś powodu ywartość nowej pozycji po prostu eksploduje. W ciągu kilku sekund staje się podobny do 334756315000.

Oto SSCCE (lub MCVE) oparty na kodzie w wiki LibGDX :

import com.badlogic.gdx.ApplicationListener;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Input;
import com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application;
import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.Sprite;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.math.Vector3;
import com.badlogic.gdx.utils.viewport.ExtendViewport;
import com.badlogic.gdx.utils.viewport.Viewport;


public class PixelMoveCameraTest implements ApplicationListener {

    static final int WORLD_WIDTH = 100;
    static final int WORLD_HEIGHT = 100;

    private OrthographicCamera cam;
    private SpriteBatch batch;

    private Sprite mapSprite;
    private float rotationSpeed;

    private Viewport viewport;
    private Sprite playerSprite;
    private Vector3 newCamPosition;

    @Override
    public void create() {
        rotationSpeed = 0.5f;

        playerSprite = new Sprite(new Texture("/path/to/dungeon_guy.png"));
        playerSprite.setSize(1f, 1f);

        mapSprite = new Sprite(new Texture("/path/to/sc_map.jpg"));
        mapSprite.setPosition(0, 0);
        mapSprite.setSize(WORLD_WIDTH, WORLD_HEIGHT);

        float w = Gdx.graphics.getWidth();
        float h = Gdx.graphics.getHeight();

        // Constructs a new OrthographicCamera, using the given viewport width and height
        // Height is multiplied by aspect ratio.
        cam = new OrthographicCamera();

        cam.position.set(0, 0, 0);
        cam.update();
        newCamPosition = cam.position.cpy();

        viewport = new ExtendViewport(32, 20, cam);
        batch = new SpriteBatch();
    }


    @Override
    public void render() {
        handleInput();
        cam.update();
        batch.setProjectionMatrix(cam.combined);

        Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);

        batch.begin();
        mapSprite.draw(batch);
        playerSprite.draw(batch);
        batch.end();
    }

    private static float MOVEMENT_SPEED = 0.2f;

    private void handleInput() {
        if (Gdx.input.isKeyPressed(Input.Keys.A)) {
            cam.zoom += 0.02;
        }
        if (Gdx.input.isKeyPressed(Input.Keys.Q)) {
            cam.zoom -= 0.02;
        }
        if (Gdx.input.isKeyPressed(Input.Keys.LEFT)) {
            newCamPosition.add(-MOVEMENT_SPEED, 0, 0);
        }
        if (Gdx.input.isKeyPressed(Input.Keys.RIGHT)) {
            newCamPosition.add(MOVEMENT_SPEED, 0, 0);
        }
        if (Gdx.input.isKeyPressed(Input.Keys.DOWN)) {
            newCamPosition.add(0, -MOVEMENT_SPEED, 0);
        }
        if (Gdx.input.isKeyPressed(Input.Keys.UP)) {
            newCamPosition.add(0, MOVEMENT_SPEED, 0);
        }
        if (Gdx.input.isKeyPressed(Input.Keys.W)) {
            cam.rotate(-rotationSpeed, 0, 0, 1);
        }
        if (Gdx.input.isKeyPressed(Input.Keys.E)) {
            cam.rotate(rotationSpeed, 0, 0, 1);
        }

        cam.zoom = MathUtils.clamp(cam.zoom, 0.1f, 100 / cam.viewportWidth);

        float effectiveViewportWidth = cam.viewportWidth * cam.zoom;
        float effectiveViewportHeight = cam.viewportHeight * cam.zoom;

        cam.position.lerp(newCamPosition, 0.02f);
        cam.position.x = MathUtils.clamp(cam.position.x,
                effectiveViewportWidth / 2f, 100 - effectiveViewportWidth / 2f);
        cam.position.y = MathUtils.clamp(cam.position.y,
                effectiveViewportHeight / 2f, 100 - effectiveViewportHeight / 2f);


        // if this is false, the "bug" (y increasing a lot) doesn't appear
        if (true) {
            Vector3 v = viewport.project(cam.position.cpy());
            System.out.println(v);
            v = viewport.unproject(new Vector3((int) v.x, (int) v.y, v.z));
            cam.position.set(v);
        }
        playerSprite.setPosition(newCamPosition.x, newCamPosition.y);
    }

    @Override
    public void resize(int width, int height) {
        viewport.update(width, height);
    }

    @Override
    public void resume() {
    }

    @Override
    public void dispose() {
        mapSprite.getTexture().dispose();
        batch.dispose();
    }

    @Override
    public void pause() {
    }

    public static void main(String[] args) {
        new Lwjgl3Application(new PixelMoveCameraTest(), new Lwjgl3ApplicationConfiguration());
    }
}

a oto isc_map.jpgdungeon_guy.png

Chciałbym również dowiedzieć się o prostszych i / lub lepszych sposobach rozwiązania tego problemu.

Joschua
źródło
„Czy istnieje na to lepszy termin?” Aliasing? Czy w takim przypadku antyaliasing Multisample (MSAA) może być alternatywnym rozwiązaniem?
Yousef Amar
@Paraknight Przepraszam, zapomniałem wspomnieć, że pracuję nad grą w piksele, w której potrzebuję wyraźnej grafiki, aby anty-aliasing nie działał (AFAIK). Dodałem tę informację do pytania. W każdym razie dzięki.
Joschua
Czy istnieje powód, aby pracować w wyższej rozdzielczości, w przeciwnym razie należy pracować w rozdzielczości 1 piksela i przeskalować wynik końcowy?
Felsir
@Felsir Tak, poruszam się w pikselach ekranu, aby gra była jak najbardziej płynna. Przeniesienie całego piksela tekstury wygląda na bardzo roztrzęsioną.
Joschua

Odpowiedzi:

22

Twój problem polega na tym, że aparat nie porusza się w pełnych pikselach. Chodzi o to, że stosunek tekstu do piksela jest nieco inny niż całkowity . Pożyczę kilka przykładów z tego podobnego pytania, na które odpowiedziałem na StackExchange .

Oto dwie kopie Mario - obie poruszają się po ekranie w tym samym tempie (albo przez duszki poruszające się w prawo na świecie, albo przez kamerę poruszającą się w lewo - kończy się to tak samo), ale tylko górna pokazuje te falujące artefakty i dolna nie:

Dwa duszki Mario

Powodem jest to, że nieznacznie przeskalowałem górnego Mario o 1 współczynnik 1,01x - ten bardzo mały ułamek oznacza, że ​​nie jest już w linii z siatką pikseli ekranu.

Niewielkie przesunięcie translacji nie stanowi problemu - filtrowanie tekstur „Najbliższe” i tak przyciąga się do najbliższego texla bez żadnego specjalnego działania.

Jednak niedopasowanie skali oznacza, że ​​przyciąganie nie zawsze jest w tym samym kierunku. W jednym miejscu piksel spoglądający w górę na najbliższy texel przesunie go nieco w prawo, podczas gdy inny piksel przesuwa jeden nieco w lewo - a teraz kolumna tekstur została albo pominięta, albo zduplikowana pomiędzy nimi, tworząc falowanie lub migotanie, które przesuwa się nad duszkiem poruszającym się po ekranie.

Oto bliższe spojrzenie. Animowałem półprzezroczystego duszka grzybowego poruszającego się płynnie, jakbyśmy mogli go renderować z nieograniczoną rozdzielczością subpikseli. Następnie nałożyłem siatkę pikseli za pomocą próbkowania najbliższego sąsiada. Cały piksel zmienia kolor, aby dopasować część duszka pod punktem próbkowania (kropka na środku).

Skalowanie 1: 1

Odpowiednio wyskalowany duszek poruszający się bez tętnienia

Mimo, że duszek przesuwa się płynnie do współrzędnych subpikseli, jego renderowany obraz wciąż przyciąga się poprawnie za każdym razem, gdy przemieści pełny piksel. Nie musimy robić nic specjalnego, aby to zadziałało.

Skalowanie 1: 1,0625

Duszek 16x16 renderowany na ekranie 17x17, pokazujący artefakty

W tym przykładzie, duszek grzybowy 16x16 texel został przeskalowany do pikseli ekranowych 17x17, więc przyciąganie odbywa się inaczej w zależności od części obrazu, tworząc falę lub falę, która rozciąga ją i zgniata podczas ruchu.

Sztuczka polega więc na dostosowaniu rozmiaru / pola widzenia kamery, tak aby źródłowe tekstury zasobów były mapowane na liczbę całkowitą pikseli ekranu. Nie musi to być 1: 1 - każda liczba całkowita działa świetnie:

Skalowanie 1: 3

Powiększ potrójną skalę

Musisz to zrobić nieco inaczej w zależności od docelowej rozdzielczości - po prostu skalowanie w celu dopasowania do okna prawie zawsze spowoduje ułamek skali. W przypadku niektórych rozdzielczości może być wymagana pewna ilość wypełnienia na krawędziach ekranu.

DMGregory
źródło
1
Po pierwsze, wielkie dzięki! To świetna wizualizacja. Głosuj za tym sam. Niestety w podanym kodzie nie mogę znaleźć niczego innego niż Integer. Rozmiar mapSpriteto 100x100, The playerSpritejest ustawiony na rozmiar 1x1, a rzutnia jest ustawiona na rozmiar 32x20. Nawet zmiana wszystkiego na wielokrotności 16 nie wydaje się rozwiązać problemu. Jakieś pomysły?
Joschua
2
Jest całkiem możliwe, aby mieć liczby całkowite wszędzie i nadal uzyskać stosunek niecałkowity. Weźmy przykład 16 ikonek teksli wyświetlanych na 17 pikselach ekranu. Obie wartości są liczbami całkowitymi, ale ich stosunek nie. Nie wiem dużo o libgdx, ale w Unity przyjrzałbym się, ile tekstur wyświetlam na jednostkę świata (np. Rozdzielczość duszka podzielona przez wielkość ducha świata), ile jednostek świata wyświetlam w moim okno (używając rozmiaru kamery) i ile pikseli ekranu jest w moim oknie. Łącząc te 3 wartości, możemy obliczyć stosunek tekstu do piksela dla danego rozmiaru okna wyświetlacza.
DMGregory
”, a rzutnia jest ustawiona na rozmiar 32x20” - chyba że „rozmiar klienta” twojego okna (obszar do renderowania) jest całkowitą wielokrotnością tego, co, jak sądzę, nie jest, 1 jednostka w twojej rzutni będzie niecałkowita liczba pikseli.
MaulingMonkey
Dzięki. Próbowałem jeszcze raz window sizeto 1000x1000 (piksele), WORLD_WIDTHx WORLD_HEIGHTto 100x100, FillViewportto 10x10, playerSpriteto 1x1. Niestety problem nadal występuje.
Joschua
Te szczegóły będą nieco starać się uporządkować w wątku komentarza. Czy chciałbyś otworzyć czat do tworzenia gier lub wysłać mi wiadomość na Twitterze @D_M_Gregory?
DMGregory
1

tutaj mała lista kontrolna:

  1. Oddziel „bieżącą pozycję kamery” od „celuj pozycją kamery”. (jak wspomniano ndenarodev) Jeśli na przykład „bieżąca pozycja kamery” wynosi 1,1 - zrób „pozycję kamery celu” 1.0

Zmieniaj tylko bieżącą pozycję kamery, jeśli kamera się poruszy.
Jeśli pozycja kamery celu jest czymś innym niż transform.position - ustaw transform.position (twojej kamery) na „celuj pozycją kamery”.

aim_camera_position = Mathf.Round(current_camera_position)

if (transform.position != aim_camera_position)
{
  transform.position = aim_camera_position;
}

to powinno rozwiązać twój problem. Jeśli nie: powiedz mi. Jednak powinieneś również rozważyć zrobienie tych rzeczy:

  1. ustaw „projekcję kamery” na „ortograficzną” (w przypadku nienormalnie rosnącego problemu y) (odtąd powinieneś powiększać tylko za pomocą „pola widzenia”)

  2. ustawić obrót kamery o 90 ° - w krokach (0 ° lub 90 ° lub 180 °)

  3. nie generuj mipmap, jeśli nie wiesz, czy powinieneś.

  4. użyj wystarczającej wielkości dla swoich duszków i nie używaj dwuliniowego lub trójliniowego filtra

  5. 5

Jeśli 1 jednostka nie jest jednostką pikseli, być może zależy to od rozdzielczości ekranu. Czy masz dostęp do pola widzenia? W zależności od pola widzenia: dowiedz się, ile rozdzielczości wynosi 1 jednostka.

float tan2Fov = tan(radians( Field of view / 2)); 
float xScrWidth = _CamNear*tan2Fov*_CamAspect * 2; 
float xScrHeight = _CamNear*tan2Fov * 2; 
float onePixel(width) = xWidth / screenResolutionX 
float onePixel(height) = yHeight / screenResolutionY 

^ A teraz, jeśli onePixel (szerokość) i onePixel (wysokość) nie mają wartości 1,0000f, po prostu przesuń te jednostki w lewo i prawo za pomocą aparatu.

OC_RaizW
źródło
Hej! Dziękuję za odpowiedź. 1) Rzeczywistą pozycję kamery i zaokrągloną już przechowuję osobno. 2) Używam LibGDX, OrthographicCamerawięc mam nadzieję, że tak powinno działać. (Używasz Unity, prawda?) 3-5) Już to robię w mojej grze.
Joschua
Okej, więc musisz dowiedzieć się, ile jednostek to 1 piksel. może to zależeć od rozdzielczości ekranu. Zredagowałem „5.” w moim poście z odpowiedzią. Spróbuj tego.
OC_RaizW
0

Nie znam libgdx, ale mam podobne problemy w innych miejscach. Myślę, że problem polega na tym, że używasz lerp i potencjalnie błąd w wartościach zmiennoprzecinkowych. Myślę, że możliwym rozwiązaniem twojego problemu jest stworzenie własnego obiektu lokalizacji kamery. Użyj tego obiektu do kodu ruchu i odpowiednio go zaktualizuj, a następnie ustaw pozycję kamery na najbliższą poszukiwaną (liczbę całkowitą?) Obiektu lokalizacji.

ndenarodev
źródło
Przede wszystkim dziękuję za wgląd. To naprawdę może mieć coś wspólnego z błędami zmiennoprzecinkowymi. Jednak problem ( ynieprawidłowy wzrost) występował również przed użyciem lerp. I rzeczywiście dodaje go wczoraj więc ludzie mogli również zobaczyć inny problem, gdzie wielkość duchy nie pozostać stałą, gdy kamera zbliża.
Joschua,