arrow for title

Illuminazione semplificata per Unity 2D usando uno screen shader

19 Ott, 2015 Sviluppo
Illuminazione semplificata per Unity 2D usando uno screen shader

Dark Deathmatch è una modalità speciale in Dolguth che colloca i combattenti in un'arena completamente buia con il mech/pilota come unica fonte di luce. In termini di gameplay, questo mette la sfida ad un livello più alto in quanto il giocatore ha una visione molto limitata dello stage (e delle sue trappole).

Qui Durgai può chiaramente individuare la posizione di Sadness Deployer, ma non può sapere se una trappola o un buco si trova tra di loro.


 

Come realizzarlo?

Il primo approccio che abbiamo seguito è stato molto ortodosso: cambiamo iterativamente lo shader di materiale di ogni sprite (incluso tile e oggetti di scena) da Sprite/Default a Sprite/Diffuse e abbiamo aggiunto un punto luce a ciascun oggetto mech/pilota (assicurando che la luce sia correttamente allineata in un mondo 2d).

Questo ha funzionato molto bene e con un risultato molto attraente. Purtroppo abbiamo avuto due problemi:

Il secondo problema non era molto grande, abbiamo deciso di rimuovere semplicemente oggetti di scena dalle arene notturne, ma le prestazioni, in un gioco come Dolguth in cui la velocità è il fattore principale, non possono essere sacrificate.

Qui entrano in scena gli effetti postprocessing/image di Unity.

Questa tecnica consente allo sviluppatore di ottenere l'accesso all'intero framebuffer risultante (in pratica si ottiene la bitmap di ciascun frame del gioco) e di modificarlo con uno shader. Effetti come nebbia globale, decolorazione in scala di grigi o tonalità seppia, possono essere facilmente realizzati con un cosiddetto "screen shader" (uno shader che accede all'intero schermo).

Nel nostro caso vogliamo che il nostro shader disegni ogni pixel come nero (aree scure) ad eccezione del cerchio virtuale (con raggio configurabile) attorno a ciascun mech/pilota.

Il primo passo è stato istruire la nostra fotocamera principale per applicare un effetto sullo schermo:

using UnityEngine;
using System.Collections;

public class NightBehaviour : MonoBehaviour {

    public Material material; 
    
    void OnRenderImage (RenderTexture source, RenderTexture destination) {
        Graphics.Blit (source, destination, material);
    }
}

La funzione OnRenderImage() viene chiamata per ogni fotogramma del gioco, l'argomento sorgente è il contenuto del framebuffer corrente, mentre la destination è dove ci si aspetta che disegni il risultato del tuo lavoro (leggi: questo è ciò che sarà effettivamente renderizzato) .

Graphics.Blit() consente di disegnare un framebuffer sorgente su una destinazione che applica un materiale (che ovviamente ha uno shader).

Il prossimo passo è la definizione del nostro shader notturno:

Shader "Sprites/Night" {
    Properties {
        _MainTex ("Base (RGB)", 2D) = "white" {}
    }
    SubShader {
        Pass {
            CGPROGRAM
            #pragma vertex vert_img
            #pragma fragment frag

            #include "UnityCG.cginc"

            uniform sampler2D _MainTex;

            float4 frag(v2f_img i) : COLOR {
                return float4(0, 0, 0, 1);
            }
            ENDCG
        }
    }
}

Questo è piuttosto stupido. Trasforma ogni pixel del framebuffer in nero. Tutto è nella notte nel nostro gioco.
Notare che _MainTex viene passato automaticamente allo shader come contenuto del framebuffer.

L'obiettivo ora è disabilitare il nero per il cerchio virtuale attorno a ciascun mech. Per farlo, dobbiamo passare la posizione di ogni mech (leggi: luce) allo shader. Usiamo un Vector4 per rappresentare una luce: x e y sono la posizione della luce, z è il raggio del cerchio virtuale (distanza della luce) e w è un flag: 0 la luce è spenta, 1 è accesa.

using UnityEngine;
using System.Collections;

public class NightBehaviour : MonoBehaviour {

    public Material material;

    
    // global reference, this is where all game data is stored
    Global gb;
    
    void OnRenderImage (RenderTexture source, RenderTexture destination) {
        // iterate each mech in the arena
        foreach (MechBehaviour mb in gb.arena.mechs_in_arena) {
            if (mb == null)
                continue;
            if (mb.dead) {
                // ensure light is turned off for dead players
                material.SetVector("_MechLight" + mb.player_id, Vector4.zero);
                continue;
            }
            // get the position of the mech
            Vector2 pos = mb.transform.position;
            // if the mech is destroyed use the pilot position 
            if (mb.destroyed)
                pos = mb.pilot.transform.position;
            // transform object world position to screen position
            Vector4 vl = Camera.main.WorldToViewportPoint(pos);
            // set light distance (the radius of the virtual circle, hardcoded)
            vl.z = 0.15f;
            // enable the light
            vl.w = 1;
            material.SetVector("_MechLight" + mb.player_id, vl);
        }
        Graphics.Blit (source, destination, material);
    }

    void Awake() {
        gb = Object.FindObjectOfType<Global> ();
    }
}

I commenti sul codice dovrebbero aiutare a comprenderlo, ma fondamentalmente stiamo passando le luci vettoriali allo shader.

Ora possiamo completare il nostro shader per tenere in considerazione le nostre luci virtuali

Shader "Sprites/Night" {
    Properties {
        _MainTex ("Base (RGB)", 2D) = "white" {}
        _AspectRatio ("Screen Aspect Ratio", Float) = 0
        _MechLight1 ("Mech1 Light", Vector) = (0, 0, 0, 0)
        _MechLight2 ("Mech2 Light", Vector) = (0, 0, 0, 0)
        _MechLight3 ("Mech3 Light", Vector) = (0, 0, 0, 0)
        _MechLight4 ("Mech4 Light", Vector) = (0, 0, 0, 0)
        _MechLight5 ("Mech5 Light", Vector) = (0, 0, 0, 0)
        _MechLight6 ("Mech6 Light", Vector) = (0, 0, 0, 0)
    }
    SubShader {
        Pass {
            CGPROGRAM
            #pragma vertex vert_img
            #pragma fragment frag

            #include "UnityCG.cginc"

            uniform sampler2D _MainTex;
            
            uniform float4 _MechLight1;
            uniform float4 _MechLight2;
            uniform float4 _MechLight3;
            uniform float4 _MechLight4;
            uniform float4 _MechLight5;
            uniform float4 _MechLight6;
            
            uniform float _AspectRatio;
            

            float4 frag(v2f_img i) : COLOR {
                float4 c = tex2D(_MainTex, i.uv);
                float2 ratio = float2(1, 1/_AspectRatio);
                float delta = 0;
                
                float ray = length((_MechLight1.xy - i.uv.xy) * ratio);
                delta += smoothstep(_MechLight1.z, 0, ray) * _MechLight1.w;
                
                ray = length((_MechLight2.xy - i.uv.xy) * ratio);
                delta += smoothstep(_MechLight2.z, 0, ray) * _MechLight2.w;
                
                ray = length((_MechLight3.xy - i.uv.xy) * ratio);
                delta += smoothstep(_MechLight3.z, 0, ray) * _MechLight3.w;
                
                ray = length((_MechLight4.xy - i.uv.xy) * ratio);
                delta += smoothstep(_MechLight4.z, 0, ray) * _MechLight4.w;
                
                ray = length((_MechLight5.xy - i.uv.xy) * ratio);
                delta += smoothstep(_MechLight5.z, 0, ray) * _MechLight5.w;
                
                ray = length((_MechLight6.xy - i.uv.xy) * ratio);
                delta += smoothstep(_MechLight6.z, 0, ray) * _MechLight6.w;
                
                c.rgb *= delta;
                return c;
            }
            ENDCG
        }
    }
}

Se non sei allineato con la programmazione dello shader, potresti trovare l'ultimo codice orribile. DRY viene ignorato, alcuni var sono a una lettera, beh, benvenuto nella programmazione shader :)

Se non sei in programmazione con lo shader, potresti trovare l'ultimo codice orribile. DRY viene ignorato, alcuni var sono a una lettera, beh, benvenuto nella programmazione shader :)

Quando scrivi gli shader devi sempre ricordare che gli "if" sono costosi e quando vuoi passare molti dati, il caricamento di texture (che saranno usate come buffer generici nello shader) è l'unico approccio praticabile. Ma anche l'"uploading" è costoso, quindi se i dati cambiano costantemente, il nuovo caricamento della texture su ogni fotogramma sarà eccessivo.

La posizione mech/pilota è una cosa che potenzialmente cambia ad ogni fotogramma, quindi avere un uniform (le variabili che puoi passare dal tuo gioco allo shader) per ogni luce è l'approccio migliore per le prestazioni. Sappiamo che ci possono essere al massimo 6 mech/piloti nel gioco, quindi definiamo 6 uniform per le luci.

Ti ricordi che gli "if" sono cattivi negli shader? Bene, questo è il motivo per cui smoothstep() è usato.

In questo contesto smoothstep() restituisce un float interpolato tra 0 e 1 in base al valore 'ray' (la distanza dal centro della luce al pixel). Se 'ray' è 0 il risultato sarà 1 (illuminazione completa per il pixel), mentre se è superiore a 'z' (la distanza di luce) sarà fissato a 0 (pixel nero). I valori tra 0 e 'z', saranno interpolati verso 1. Il risultato è moltiplicato per la 'w' del vettore, che puoi vedere come un flag per riconoscere la luce spenta (hanno 'w' impostato su 0) .

Una cosa bella di questo approccio è che più il pixel è lontano dalla luce, più scuro sarà. Ciò assicura un cerchio di luce uniforme attorno al mech.

Cos'è il "ratio"?

Il nostro gioco è 16:9, quindi calcolare la distanza tra due punti darà come risultato un'ellisse invece di un cerchio. Anche se avremmo potuto codificare quel valore nello shader (beh, è sempre 16.0f/9.0f), preferiamo passarlo dal motore di gioco, perché in futuro potremmo voler supportare altre proporzioni.

L'ultima nota riguarda il valore delta, sommandolo consente al nostro shader di generare un'illuminazione più intensa quando più mech/piloti sono vicini.

 

Roberto De Ioris

Blog

Potrebbe interessarti anche:

contattaci

Hai una buona idea ma non sai che pesci prendere?

Parlaci del tuo progetto