¿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.
- Fundamentos de física para juegos: Newton en código
- Sistema de colisiones eficiente (Quadtree spatial indexing)
- Gravedad, fricción y rebotes realistas
- Particle system optimizado (60 FPS con 1000+ particles)
- Trucos de performance críticos
🌍 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.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"
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:
- Velocity vectors: Dibuja flechas mostrando dirección/magnitud
- Collision boxes: Outlines de bounds para debug
- FPS meter: Monitor en tiempo real
- Particle count: Ver cuántas partículas activas
- Physics stats: Avg collision checks per frame
// 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
- deltaTime es sagrado: Sin él, física inconsistente cross-device
- Spatial indexing = obligatorio: A 100+ objetos, performance colapsa sin él
- Floating point errors acumulan: Usa epsilon (0.0001) para comparaciones
- Restitution < 1.0: Rebotes perfectos (1.0) causan jitter infinito
- 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.