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.

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:

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: