Implementando lançamento de projéteis em simulações de física
O lançamento de projéteis é um dos problemas mais comuns em desenvolvimento de jogos e simulações, mas também um dos mais mal implementados que eu já vi rodando em produção. A diferença entre algo que funciona aceitavelmente e algo que funciona bem mora nos detalhes de integração numérica e na escolha da estratégia de deteção de colisão. Vou começar pelo método mais prático porque a maioria dos artigos na internet começa por definições teóricas e termina sem ninguém saber o que colocar no código. A abordagem que eu uso rotineiramente combina integração semi-implícita de Euler com verificação por interseção de segmento esférico, e funciona bem para a esmagadora maioria dos casos em tempo real.
Configurando o lançamento de projéteis corretamente
O passo a passo prático é o seguinte. Primeiro, calcule a velocidade inicial do projétil multiplicando o vetor de direção normalizado pela magnitude da velocidade que você definiu. A direção normalmente vem de uma câmera ou de um transformador de arma. Depois, a cada frame, atualize a posição usando o método semi-implícito: primeiro atualize a velocidade com a aceleração (gravidade, usualmente), depois atualize a posição com a nova velocidade. Isso é significativamente mais estável que o Euler explícito padrão, especialmente com passos de tempo grandes ou gravidade forte. A parte que todo mundo erra é a interseção com o cenário. Se você simplesmente verificar a posição do projétil frame a frame, projéteis rápidos vão atravessar paredes. O que você precisa fazer é testar o segmento de linha entre a posição anterior e a posição atual contra todas as colisões possíveis no caminho. Para colisoras esféricas, isso é resolvido encontrando o valor t na faixa de 0 a 1 onde a distância do segmento ao centro da esfera é igual ao raio. Para meshes triangulares, use interseção segmento-triangulo clássica com a variant de Møller–Trumbone, que é rápida e numéricamente estável quando implementada com cuidado.
Eu tive um problema específico num projeto de tiro tático onde projéteis de alta velocidade (cerca de 800 unidades por segundo) atravessavam paredes finas de 0,5 unidades mesmo com a verificação de segmento ativa. O problema não era o algoritmo em si, era que o passo de tempo variável do motor causava saltos grandes demais entre frames em hardware menos potente. A solução foi splitar o segmento em subsegmentos menores quando o tamanho do excedia uma fração do menor colisor relevante no cenário. Basicamente, se o projétil viaja mais que 10% do tamanho da menor caixa delimitadora de colisão naquele frame, você divide o caminho em partes iguais e testa cada parte individualmente. Isso aumentou o custo de CPU em cerca de 3 a 5 por cento no profile que eu fiz, mas eliminou completamente o tunneling. Dentro do lançamento de projéteis, há outro detalhe que parece óbvio mas raramente é aplicado corretamente: o tratamento de projéteis com gravidade. A trajetória balística real exige que a gravidade seja aplicada apenas à componente vertical da velocidade, não ao vetor completo de direção. Muitos desenvolvedores aplicam gravidade diretamente ao vetor velocidade resultante e se perguntam por que o projétil parece "cair" de forma artificialmente acentuada nos primeiros frames. O problema é que, ao normalizar a direção inicial, você perde informação sobre a componente vertical que seria natural numa trajetória parabólica verdadeira. A correção é simples: calcule a direção horizontal separadamente da velocidade vertical inicial, e recomponha apenas para fins de renderização.
👉 Clique no botão abaixo para saber mais sobre o assunto!
Outro ponto que merece atenção é a ordem das operações de colisão. Se você tem múltiplos tipos de colisor no cenário — terrenos, objetos dinâmicos, partículas — a ordem em que verifica colisão afeta diretamente o comportamento. Colisoras estáticas devem ser verificadas antes das dinâmicas, porque as dinâmicas podem estar em movimento e seu posicionamento exato no momento da colisão depende do passo de integração. Verificar na ordem errada pode fazer o projétil responder a uma colisão que, na realidade, ainda não aconteceu no espaço de estados do simulador. Para projéteis que precisam de trail visual, não use partículas spawned na posição atual do projétil. Use um sistema de linha que conecte posições anteriores embuffer, com fade-out por idade. Spawnar partículas por frame gera um overhead desnecessário e o resultado visual fica granulado em vez de suave. Um buffer circular de cerca de vinte posições anteriores, renderizado como um pipeline de triângulos ou linha espessa, é mais eficiente e visualmente superior na maioria dos casos.
O lançamento de projéteis também demanda considerações de balanceamento quando o contexto é um jogo. Velocidades muito altas tornam a jogabilidade baseada em reflexos, o que pode ser desejável ou não. Velocidades moderadas com trajetórias visíveis favorecem leitura de jogo e previsão. Isso não é física, é design, mas a implementação física dita os limites do que o design pode fazer. Se seu sistema de colisão for impreciso, o jogador vai perceber como inconsistência, não como design intencional. Uma limitação importante do método descrito aqui é que ele não lida bem com projéteis que sofrem forças laterais significativas durante o voo, como vento forte ou curvatura de Coriolis em escalas muito grandes. Nesse caso, a integração semi-implícita ainda funciona, mas a detecção de colisão por segmento de linha assume trajetória retilínea entre frames, o que não é mais válido. A alternativa é usar integração de Verlet ou RK4 com passos adaptativos, o que aumenta o custo computacional em aproximadamente três a quatro vezes e ainda assim pode perder colições em cenários extremamente caóticos. Se você está fazendo simulação balística de longo alcance, considere usar um solucionador especializado em vez de adaptar o sistema de tiro padrão.
Para quem quer implementar isso do zero, o fluxo básico de código em pseudo-código seria: inicializar posição e velocidade, entrar no loop de atualização onde se aplica gravidade à velocidade, calcula-se o segmento de movimento, verifica-se colisão ao longo do segmento, e se não houver colisão, move-se o projétil para a nova posição. Se houver colisão, resolve-se a resposta apropriada — destruição do projétil, ricochete, penetração — dependendo do que o sistema suporta. O custo típico de uma execução completa de lançamento de projéteis com colisão contra um cenário médio de cem entidades estáticas gira em torno de 0,05 a 0,2 milissegundos por projétil em hardware moderno, dependendo da complexidade dos colisores. Isso permite processar centenas de projéteis simultaneamente sem impacto perceptível no framerate. Se o número sobe para milhares, aí você precisa pensar em spatial partitioning ou broad-phase optimizations, mas esse é um problema diferente.