Tudo começa comigo jogando Silent Hill 2, é possível ver sombras dinâmicas com o formato dependendo da direção, e da mesh do objeto afetado.

Imagem Silent Hill

E a pergunta é… Mas como?

Então nós começamos, primeiro, usamos PS2SDK e C. Com meu próprio subset de C “com classes”. Lembrando que o PS2SDK é quase linux-only. Eu passei alguns dias tentando fazer o setup pelo WSL/MSYS2 mas isto só vai dar dor de cabeça. Então certo, temos o projeto funcionando. Mas… temos que assumir várias coisas. Um que você sabe como renderizar modelos enviando GIFs (Graphics Interface) e sabe “manipular dados”, aka, passar modelos 3D para binários e lê-los pelo código/loop do jogo. Eu vou postar artigos em como isso pode ser possível mas não é o ponto deste aqui!

Ok, assumindo isso tudo… Mas como? As vezes parece que não era para ser possível mesmo, pois o Graphics Synthesizer, a GPU do PS2, só recebe informações já pre-processadas. Ou seja, basicamente, você já tem que mandar tudo para como vai chegar na tela. Os pontos na tela já processados, as cores, as texturas, etc. Ele só vai renderizar o que já foi processado, diferente de pipelines de GPU modernas, e, também ao contrário de GPUs modernas, ela não é programável e tem uma Fixed-Pipeline, basicamente você só seta as configurações que quer. Semelhante a GPUs antigas dos anos 90. Ok, agora que você sabe disso, como?

Então digamos que estamos renderizando um modelo, nesse caso um modelo da Heather do Silent Hill 3, rippado. O que você precisa fazer é basicamente tunelar o modelo. Como assim? basicamente, você vai pegar as faces opostas a algum ponto, digamos uma “““LÂMPADA”””, no caso seria somente um ponto em world-position, você vai pegar a posição desse ponto no mundo, fazer um cálculo de todas as faces do modelo (neste caso a heather) que estão na DIREÇÃO OPOSTA à lâmpada. I.E:

Imagem Tunel

VECTOR lamp_pos = {10, 10, 10, 0}; // As coordenadas são X, Y, Z, o zero no final é porque a CPU (Emotion Engine, EE) tem que ter endereços alinhados se não você crasha.
VECTOR heather_pos = {0, 0, 0, 0}; // Na origem

Agora, você loopa TODAS as faces do modelo.


Entity *heather = get_EntityByName("HEATHER");
for (int i = 0; i < mesh->linear_count; i += 3) {
    VECTOR v_local[3];

    // Vamos olhar vértice por vértice de cada triângulo
    for (int v = 0; v < 3; v++) {
        int idx = i + v;
        float vx = mesh->linear_vertices[idx][0];
        float vy = mesh->linear_vertices[idx][1];
        float vz = mesh->linear_vertices[idx][2];
        
        float nx = mesh->linear_normals[idx][0];
        float ny = mesh->linear_normals[idx][1];
        float nz = mesh->linear_normals[idx][2];

        // 1. Calcula a direção: da luz para o vértice
        float lx = mesh->lights[0][0] - vx;
        float ly = mesh->lights[0][1] - vy;
        float lz = mesh->lights[0][2] - vz;

        // 2. Calcula a direção oposta: do vértice fugindo da luz
        float px = vx - mesh->lights[0][0];
        float py = vy - mesh->lights[0][1];
        float pz = vz - mesh->lights[0][2];

        // 3. O Dot Product diz pra onde a face tá apontando.
        float dot = (nx * lx) + (ny * ly) + (nz * lz);
        float shadow_epsilon = 0.0f;
        
        // Se der negativo, a face tá de costas pra luz.
        if (dot < shadow_epsilon) {
            // Empurra o vértice na direção oposta da luz, esticando a face!
            vx += px * mesh->lights[0][3];
            vy += py * mesh->lights[0][3];
            vz += pz * mesh->lights[0][3];
        }

        v_local[v][0] = vx;
        v_local[v][1] = vy;
        v_local[v][2] = vz;
        v_local[v][3] = 1.0f;
    }
    // ... transforma pra screen space ...
}

Ok, agora que todas as faces que não estão “em direção” a lâmpada estão meio que selecionadas, você joga elas na direção contrária. Agora seu personagem vai ficar igual um túnel. Isso é uma técnica que é comumente usada com Stencil Shadows normais mesmo.

E agora? O personagem é um túnel? não. Você faz dois passes, um com o personagem normal renderizando, e o outro com o personagem tunelado. O seu personagem normal é o personagem que o player vai ver, e o tunelado, onde o “volume” encostar, ficará a sombra. Vale lembrar que o objeto cópia deve copiar a pose, tudo do personagem original, caso contrário sua sombra será um T-Pose e o personagem em Idle ou algo do gênero.

Mas como você diz pra GPU o que é a frente do túnel e o que é o fundo do túnel?

Você faz isso calculando o Cross Product 2D logo depois da projeção dos vértices na tela. Se o resultado for positivo, a face está olhando para a câmera (front pass). Se for negativo, está de costas (back pass). Com as faces divididas em duas listas, nós mandamos elas para a GPU desenhar. E é aqui que os problemas começam.

// Cross Product 2D projetado na tela para achar a orientação (Front vs Back)
float dx1 = sx[1] - sx[0]; float dy1 = sy[1] - sy[0];
float dx2 = sx[2] - sx[0]; float dy2 = sy[2] - sy[0];
float cross = (dx1 * dy2) - (dx2 * dy1);

// Separação em listas de DMA
if (cross > 0.0f) {
    for (int k = 0; k < 3; k++) {
        front_pass[front_count].x = (u16)(sx[k] * 16.0f);
        front_pass[front_count].y = (u16)(sy[k] * 16.0f);
        front_pass[front_count].z = (u32)(sz[k]);
        front_count++;
    }
} else {
    for (int k = 0; k < 3; k++) {
        back_pass[back_count].x = (u16)(sx[k] * 16.0f);
        back_pass[back_count].y = (u16)(sy[k] * 16.0f);
        back_pass[back_count].z = (u32)(sz[k]);
        back_count++;
    }
}

Agora vem o truque me fez demorar muito tempo:

O PS2 não tem stencil buffer. Em hardwares modernos você basicamente tem um buffer (uma zona de memória quase que sandbox) onde você pode armazenar informação e usar da maneira que bem entender. Mas no PS2 não temos isso. A única coisa que temos são framebuffers.

Então normalmente, o setup fica:

Front buffer (Que está atualmente na tela): (LarguraxAltura)*4, então, digamos que sua tela é 640x480, isto dá, (307200)*4, que é, 1228800 bytes. Isto é ~1.2MB
Back buffer (O que a GPU está atualmente desenhando em): Também 1.2MB.

Vale lembrar que o PS2 tem 4MB de VRAM. Isso mesmo. Creio que você já vê o problema. 1.2MB * 2 é 2.4MB. E como antes dito, o PS2 não tem um stencil buffer. Então é isso mesmo. Você precisa de OUTRA tela somente para efeitos especiais. Ou seja +1.2MB para outro buffer. Agora você está usando 3.6MB de VRAM.

Mas… mas por quê não usar os outros buffers já utilizados? E se eu fosse bem engenhoso e usasse o Alpha Buffer (8 bits) do front ou back buffer como storage? então eu poderia, não usar alpha, mas ter o canal alpha como meu stencil buffer! Boa linha de pensamento, mas não. Se você acessar o guia do Graphics Synthesizer, GS Users Manual.pdf, você vai ver que não existe Z-testing com alpha. O que te força a ter algum buffer, R, G, ou B como stencil buffer.

Para manchar esse terceiro buffer, a gente desliga a escrita de Z (disable_z_write) para a geometria do túnel não bugar o chão do mapa, e interagir com o Alpha.

No primeiro passe (Front pass), você simplesmente desenha as faces da frente do túnel manchando o canal Alpha do buffer. No segundo passe (Back pass), vem a mágica: Você configura o registrador da GPU ligando o DATE (Destination Alpha Test Enable). Isso diz pro hardware: “SÓ desenhe essa parte de trás do túnel EXATAMENTE nos pixels onde a parte da frente acabou de manchar”. Fazer isso injetando tags de DMA manualmente no meio do loop mataria o código. Para resolver, eu empacoto isso em funções helper de C. Por exemplo, draw_inject_gs_register(q, registro, valor) enfia as trocas cruéis de estado do GS direto nos registradores via pacotes A+D (Address + Data).

Ver código avançado: Por dentro das Funções Helper (DMA & GIF Tags)
// Helper para injetar dados direto nos registradores da GPU via tag A+D
qword_t* draw_inject_gs_register(qword_t *q, u8 reg_addr, u64 data) {
    // 1. QWORD 0: The GIF Tag
    // NLOOP=1, EOP=1 (End of Packet), NREG=1
    q->dw[0] = 0x1000000000008001ULL; 
    
    // Register Descriptor: 0x0E means A+D (Address + Data) mode
    q->dw[1] = 0x000000000000000EULL; 
    q++;

    // 2. QWORD 1: The Payload
    q->dw[0] = data;          // Lower 64 bits: The actual data for the register
    q->dw[1] = (u64)reg_addr; // Upper 64 bits: The target GS register address
    q++;

    return q; // Return the incremented pointer to keep the chain going!
}
qword_t* graph_draw_fullscreen_textured_q(qword_t *q, texbuffer_t *tex, u32 clut_addr, lod_t *lod, int quad_w, int quad_h, u8 r, u8 g, u8 b, u8 a) {
    
    clutbuffer_t active_clut = {0};
    active_clut.address = clut_addr;
    active_clut.psm = GS_PSM_32;
    active_clut.storage_mode = CLUT_STORAGE_MODE1;
    active_clut.start = 0;
    active_clut.load_method = CLUT_LOAD;

    q = draw_texturebuffer(q, 0, tex, &active_clut);
    q = draw_texture_sampling(q, 0, lod);
    q = draw_inject_gs_register(q, GS_REG_TEXFLUSH, 0);

    q->dw[0] = 0x1000000000008006ULL;
    q->dw[1] = 0x000000000000000EULL; q++;

    q->dw[0] = GIF_SET_PRIM(6, 0, 1, 0, 1, 0, 0, 0, 0)
    q->dw[1] = 0x00ULL; q++;

    q->dw[0] = GS_SET_RGBAQ(r, g, b, a, 0);
    q->dw[1] = 0x01ULL; q++;

    int half_w = quad_w / 2;
    int half_h = quad_h / 2;

    q->dw[0] = 0x00ULL; 
    q->dw[1] = 0x02ULL; q++; 
    q->dw[0] = GIF_SET_XYZ((2048 - half_w)<<4, (2048 - half_h)<<4, 0);
    q->dw[1] = 0x05ULL; q++;

    union { float f; u32 i; } s, t;
    s.f = 1.0f; 
    t.f = 448.0f / 512.0f;
    
    q->dw[0] = (u64)s.i | ((u64)t.i << 32);
    q->dw[1] = 0x02ULL; q++;
    q->dw[0] = GIF_SET_XYZ((2048 + half_w)<<4, (2048 + half_h)<<4, 0);
    q->dw[1] = 0x05ULL; q++;

    return q;
}
// Desliga escrita de Z no buffer principal
u64 disable_z_write = (zbp) | (zpsm << 24) | ((u64)1 << 32);
q = draw_inject_gs_register(q, 0x4E, disable_z_write); 

// PASSE 1: Injeta a geometria da frente do túnel (Front Pass)
u64 normal_z_test = (0ULL << 0) | (0ULL << 14) | (0ULL << 15) | (1ULL << 16) | (2ULL << 17);
q = draw_inject_gs_register(q, 0x47, normal_z_test);

u64 *dw = (u64*)draw_prim_start(q, 0, &mesh->prim, &color);
for(int i = 0; i < front_count; i++) {
    *dw++ = 0ULL;
    *dw++ = 0x00000001ULL;
    *dw++ = front_pass[i].xyz;
}
q = draw_prim_end((qword_t*)dw, 3, 0x412);

// PASSE 2: O fundo do túnel com DATE (Destination Alpha Test Enable) ligado
u64 back_pass_test = (0ULL << 0)    
                   | (7ULL << 1) 
                   | (1ULL << 14)   // DATE = ON (Testa o Alpha destino!)
                   | (1ULL << 15)   // DATM = 1 
                   | (1ULL << 16)   // ZTE = 1
                   | (3ULL << 17);  // ZTST = GEQUAL

q = draw_inject_gs_register(q, 0x47, back_pass_test);

u64 *dw2 = (u64*)draw_prim_start(q, 0, &mesh->prim, &color2);
for(int i = 0; i < back_count; i++) {
    *dw2++ = 0ULL;
    *dw2++ = 0x00000001ULL;
    *dw2++ = back_pass[i].xyz;
}
q = draw_prim_end((qword_t*)dw2, 3, 0x412);

O que sobra desenhado na memória virtual depois disso é o miolo do volume onde a sombra deve bater no cenário.

Mas os problemas ainda não acabaram. Você manchou o buffer fantasma, mas a tela principal (Back buffer) continua intacta. Como passamos a sombra para o jogo?

Você vai desenhar um Quad (dois triângulos) que ocupa a tela inteira, onde o target é a tela do jogo, mas a textura é o nosso pseudo-stencil. Antes de desenhar, você vai ter que fazer um BITBLT para converter esse nosso buffer pesado para 8-bits, e alocar uma Color Look Up Table (CLUT) para traduzir essa mancha em pixels escuros.

Funções gigantes como desenhar o quad na tela inteira, usando a textura do buffer convertida, eu encapsulo no helper graph_draw_fullscreen_textured_q(), que gera a corrente DMA pesada e aplica a paleta por cima da tela pra mim. I.E:

// Local-to-local copy: O pseudo-stencil PSM_32 -> PSM_8 para podermos usar a CLUT
u64 bitbltbuf = (stencil.address / 64)
              | ((u64)(stencil.width / 64) << 16)
              | ((u64)stencil.psm << 24)
              | ((u64)(stencil8.address / 64) << 32)
              | ((u64)(stencil8.width / 64) << 48)
              | ((u64)stencil8.psm << 56);

// ... (Injeção dos pacotes de TRXPOS, TRXREG, TRXDIR para o BitBlt) ...

// Setup da CLUT: A tradutora da sombra
static u32 clut_address = 0;
if (clut_address == 0) {
    clut_address = graph_vram_allocate(16, 16, GS_PSM_32, GRAPH_ALIGN_BLOCK);
    
    static u32 shadow_palette[256] __attribute__((aligned(16)));
    memset(shadow_palette, 0, sizeof(shadow_palette));
    
    // O pulo do gato: Índice 0 fica transparente. 
    // Qualquer área manchada (1 a 255) vira preto com 50% de Alpha (128).
    for(int z = 1; z < 256; z++){
        shadow_palette[z] = 0x80000000; 
    }
    
    upload_texture_to_vram(shadow_palette, 16, 16, GS_PSM_32, clut_address);
}

// Configura o buffer convertido como textura de 8 bits
static texbuffer_t stencil8_tex;
stencil8_tex.info.width = log2(stencil8.width);
stencil8_tex.info.height = log2(stencil8.width);
stencil8_tex.info.components = TEXTURE_COMPONENTS_RGBA;
stencil8_tex.address = stencil8.address;
stencil8_tex.width = stencil8.width;
stencil8_tex.psm = GS_PSM_8;

// Desenha o Quad fullscreen usando a textura manchada e a CLUT
q = draw_inject_gs_register(q, 0x47,
    (1ULL << 0)       |  // ATE=1
    (5ULL << 1)       |  // ATST=EQUAL
    (0x01ULL << 4)    |  // AREF=128
    (0ULL << 12)      |  // AFAIL=KEEP
    (0ULL << 16));       // ZTE=0

q = graph_draw_fullscreen_textured_q(q, &stencil8_tex, clut_address, &mesh->texture->lod, 512, 448, 128, 128, 128, 64);

No final, você tem isso: