🔬 Desarrollo Técnico

Física Realista en Juegos Web: Caso Neon Lab Physics

📅 4 de Octubre, 2024 ⏱️ 11 minutos 🧪 Técnico

¿Física realista en JavaScript? Sí, es posible. Cuando decidimos crear Neon Lab Physics WOW, el desafío era claro: simular física de partículas ultra realista usando solo Vanilla JavaScript, sin engines pesados como Matter.js o Box2D.

El resultado: un simulador que corre a 60 FPS con 500+ partículas simultáneas, colisiones precisas y efectos visuales espectaculares. Esta es la historia técnica completa.

⚡ En esta guía técnica:

🌍 Fundamentos: Las 3 Leyes de Newton en JavaScript

Toda física de juegos se reduce a las leyes de Newton. Así las implementamos:

Primera Ley: Inercia

"Un objeto en movimiento permanece en movimiento a menos que una fuerza actúe sobre él"

velocity.x += acceleration.x * deltaTime;
velocity.y += acceleration.y * deltaTime;
position.x += velocity.x * deltaTime;
position.y += velocity.y * deltaTime;

En práctica: Cada frame, actualizamos velocidad basado en aceleración, luego posición basada en velocidad. deltaTime es crítico para consistencia cross-device.

Segunda Ley: F = ma

"Fuerza = Masa × Aceleración"

acceleration = force / mass;

Implementación:

function applyForce(body, force) {
  const ax = force.x / body.mass;
  const ay = force.y / body.mass;
  body.acceleration.x += ax;
  body.acceleration.y += ay;
}

Objetos pesados (high mass) se mueven menos con la misma fuerza. Realismo instant.

Tercera Ley: Acción-Reacción

"Para cada acción hay una reacción igual y opuesta"

En colisiones:

// Cuando A golpea B:
A.velocity.x = -A.velocity.x * restitution;
B.velocity.x = -B.velocity.x * restitution;
// Ambos rebotan en direcciones opuestas

⚙️ Sistema de Colisiones: El Verdadero Desafío

El algoritmo naive de colisiones es O(n²): comparar cada objeto con todos los demás. Con 500 partículas = 250,000 comparaciones por frame. Imposible a 60 FPS.

Solución: Quadtree Spatial Indexing

Dividimos el espacio en cuadrantes. Solo comparamos objetos en el mismo cuadrante o vecinos.

class Quadtree {
  constructor(bounds, capacity = 4) {
    this.bounds = bounds; // {x, y, width, height}
    this.capacity = capacity;
    this.objects = [];
    this.divided = false;
  }

  insert(object) {
    if (!this.bounds.contains(object)) return false;
    
    if (this.objects.length < this.capacity) {
      this.objects.push(object);
      return true;
    }

    if (!this.divided) this.subdivide();
    
    return (this.northeast.insert(object) ||
      this.northwest.insert(object) ||
      this.southeast.insert(object) ||
      this.southwest.insert(object));
  }

  subdivide() {
    // Divide en 4 cuadrantes
    const {x, y, width, height} = this.bounds;
    const hw = width / 2, hh = height / 2;
    
    this.northeast = new Quadtree({x: x+hw, y, width: hw, height: hh});
    this.northwest = new Quadtree({x, y, width: hw, height: hh});
    this.southeast = new Quadtree({x: x+hw, y: y+hh, width: hw, height: hh});
    this.southwest = new Quadtree({x, y: y+hh, width: hw, height: hh});
    
    this.divided = true;
  }
}

Resultado: Reducción de O(n²) a O(n log n). Con 500 partículas: de 250K comparaciones a ~4,500. 98% más rápido.

🎱 Colisiones Circulares: Matemática Precisa

En Neon Lab Physics, todas las partículas son círculos. Detectar colisión círculo-círculo es simple pero requiere precisión:

function checkCircleCollision(a, b) {
  const dx = b.x - a.x;
  const dy = b.y - a.y;
  const distance = Math.sqrt(dx*dx + dy*dy);
  const minDist = a.radius + b.radius;
  
  if (distance < minDist) {
    // Colisión detectada
    resolveCollision(a, b, distance, dx, dy);
  }
}

Resolución de Colisión (El Truco Crítico)

No basta detectar, hay que resolver: separar objetos + transferir momentum.

function resolveCollision(a, b, distance, dx, dy) {
  // 1. Separar objetos (evita overlap)
  const overlap = (a.radius + b.radius) - distance;
  const nx = dx / distance; // normal x
  const ny = dy / distance; // normal y
  
  a.x -= nx * overlap * 0.5;
  a.y -= ny * overlap * 0.5;
  b.x += nx * overlap * 0.5;
  b.y += ny * overlap * 0.5;

  // 2. Transferir momentum
  const relVelX = b.vx - a.vx;
  const relVelY = b.vy - a.vy;
  const dotProduct = relVelX * nx + relVelY * ny;

  // Solo resolver si se acercan (no si ya se alejan)
  if (dotProduct < 0) return;

  const restitution = 0.8; // bounciness
  const impulse = (2 * dotProduct) / (a.mass + b.mass);

  a.vx += impulse * b.mass * nx * restitution;
  a.vy += impulse * b.mass * ny * restitution;
  b.vx -= impulse * a.mass * nx * restitution;
  b.vy -= impulse * a.mass * ny * restitution;
}

Pro tip: restitution controla "bounciness". 1.0 = rebote perfecto (energía conservada), 0.0 = sin rebote (energía absorbida).

🌊 Gravedad & Fuerzas Ambientales

Gravedad es simplemente aceleración constante hacia abajo:

const GRAVITY = 9.8; // m/s² (Earth-like)
const PIXEL_TO_METER = 100; // 100px = 1 metro

function applyGravity(particle, deltaTime) {
  const gravityForce = GRAVITY / PIXEL_TO_METER;
  particle.vy += gravityForce * deltaTime * 60; // 60 FPS normalize
}

Friction (Air Resistance)

Sin fricción, los objetos nunca paran. Añadimos damping:

const AIR_RESISTANCE = 0.995; // 0.5% de velocidad perdida por frame

particle.vx *= AIR_RESISTANCE;
particle.vy *= AIR_RESISTANCE;

Paredes & Límites

Cuando una partícula golpea una pared, invertimos velocidad:

if (particle.x - particle.radius < 0) {
  particle.x = particle.radius;
  particle.vx = -particle.vx * WALL_RESTITUTION;
}
if (particle.x + particle.radius > canvas.width) {
  particle.x = canvas.width - particle.radius;
  particle.vx = -particle.vx * WALL_RESTITUTION;
}
// Mismo para y-axis

✨ Particle System: 1000+ Partículas a 60 FPS

Neon Lab Physics puede simular 1000+ partículas simultáneamente. El secreto: object pooling + dirty checking.

Object Pooling

No crear/destruir objetos cada frame. Reutilizar:

const particlePool = [];
const activeParticles = [];

function spawnParticle(x, y) {
  let p = particlePool.pop() || new Particle();
  p.reset(x, y);
  activeParticles.push(p);
  return p;
}

function despawnParticle(particle) {
  activeParticles.splice(activeParticles.indexOf(particle), 1);
  particlePool.push(particle);
}

Ganancia: 0 garbage collection pauses. FPS ultra estable.

Dirty Checking

Solo renderiza partículas que se movieron significativamente:

particle.lastX = particle.x;
particle.lastY = particle.y;

// Más tarde...
const moved = Math.abs(particle.x - particle.lastX) > 0.5 ||
              Math.abs(particle.y - particle.lastY) > 0.5;

if (moved) {
  renderParticle(particle);
}

🎨 Efectos Visuales: Trails & Glow

Física realista + efectos neon = Neon Lab Physics WOW.

Motion Trails

// Trail con alpha decay
ctx.globalAlpha = 0.3; // Persistencia del trail
ctx.drawImage(canvas, 0, 0); // Copia frame anterior
ctx.globalAlpha = 1.0; // Full opacity para nuevas partículas

Glow Effect

ctx.shadowBlur = 20;
ctx.shadowColor = particle.color;
ctx.fillStyle = particle.color;
ctx.beginPath();
ctx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2);
ctx.fill();

📊 Optimización: El Pipeline Completo

Nuestro update loop optimizado:

function gameLoop(timestamp) {
  const deltaTime = (timestamp - lastTime) / 1000;
  lastTime = timestamp;

  // 1. Build spatial index
  quadtree = new Quadtree(worldBounds);
  particles.forEach(p => quadtree.insert(p));

  // 2. Apply forces
  particles.forEach(p => {
    applyGravity(p, deltaTime);
    applyAirResistance(p);
  });

  // 3. Update positions
  particles.forEach(p => p.update(deltaTime));

  // 4. Check collisions (usando quadtree)
  particles.forEach(p => {
    const nearby = quadtree.query(p.getBounds());
    nearby.forEach(other => {
      if (p !== other) checkCollision(p, other);
    });
  });

  // 5. Render
  render();

  requestAnimationFrame(gameLoop);
}

🔧 Herramientas & Debugging

Desarrollar física realista requiere debugging visual:

// Debug overlay
ctx.fillStyle = 'white';
ctx.font = '14px monospace';
ctx.fillText(`FPS: ${fps}`, 10, 20);
ctx.fillText(`Particles: ${particles.length}`, 10, 40);
ctx.fillText(`Collisions: ${collisionChecks}`, 10, 60);

🎓 Lecciones Aprendidas

  1. deltaTime es sagrado: Sin él, física inconsistente cross-device
  2. Spatial indexing = obligatorio: A 100+ objetos, performance colapsa sin él
  3. Floating point errors acumulan: Usa epsilon (0.0001) para comparaciones
  4. Restitution < 1.0: Rebotes perfectos (1.0) causan jitter infinito
  5. Object pooling > todo: GC pauses matan smooth physics

Física realista en JavaScript no es magia, es matemática + optimización. Con las técnicas correctas, puedes simular mundos físicos complejos a 60 FPS sólido. Neon Lab Physics es prueba de que el browser puede competir con engines nativos.

�� Continúa aprendiendo:
← Volver al Blog