用JavaScript玩转游戏物理(一)运动学模拟与粒子系统


Posted in Javascript onJune 19, 2010

系列简介
也许,三百年前的艾萨克·牛顿爵士(Sir Issac Newton, 1643-1727)并没幻想过,物理学广泛地应用在今天许多游戏、动画中。为什么在这些应用中要使用物理学?笔者认为,自我们出生以来,一直感受着物理世界的规律,意识到物体在这世界是如何"正常移动",例如射球时球为抛物线(自旋的球可能会做成弧线球) 、石子系在一根线的末端会以固定频率摆动等等。要让游戏或动画中的物体有真实感,其移动方式就要符合我们对"正常移动"的预期。
今天的游戏动画应用了多种物理模拟技术,例如运动学模拟(kinematics simulation)、刚体动力学模拟(rigid body dynamics simulation)、绳子/布料模拟(string/cloth simulation)、柔体动力学模拟(soft body dynamics simulation)、流体动力学模拟(fluid dynamics simulation)等等。另外碰撞侦测(collision detection)是许多模拟系统里所需的。
本系列希望能介绍一些这方面最基础的知识,继续使用JavaScript做例子,以即时互动方式体验。
本文简介
作为系列第一篇,本文介绍最简单的运动学模拟,只有两条非常简单的公式。运动学模拟可以用来模拟很多物体运动(例如马里奥的跳跃、炮弹等),本文将会配合粒子系统做出一些视觉特效(粒子系统其实也可以用来做游戏的玩法,而不单是视觉特效)。
运动学模拟
运动学(kinematics)研究物体的移动,和动力学(dynamics)不同之处,在于运动学不考虑物体的质量(mass)/转动惯量(moment of inertia),以及不考虑加之于物体的力(force )和力矩(torque)。
我们先回忆牛顿第一运动定律:
当物体不受外力作用,或所受合力为零时,原先静止者恒静止,原先运动者恒沿着直线作等速度运动。该定律又称为「惯性定律」。此定律指出,每个物体除了其位置(position)外,还有一个线性速度(linear velocity)的状态。然而,只模拟不受力影响的物体并不有趣。撇开力的概念,我们可以用线性加速度(linear acceleration)去影响物体的运动。例如,要计算一个自由落体在任意时间t的y轴座标,可以使用以下的分析解(analytical solution):
用JavaScript玩转游戏物理(一)运动学模拟与粒子系统
当中,和分别是t=0时的y轴起始座标和速度,而g则是重力加速度(gravitational acceleration)。
这分析解虽然简单,但是有一些缺点,例如g是常数,在模拟过程中不能改变;另外,当物体遇到障碍物,产生碰撞时,这公式也很难处理这种不连续性(discontinuity) 。
在计算机模拟中,通常需要计算连续的物体状态。用游戏的用语,就是计算第一帧的状态、第二帧的状态等等。设物体在任意时间t的状态:位置矢量为、速度矢量为、加速度矢量为。我们希望从时间的状态,计算下一个模拟时间的状态。最简单的方法,是采用欧拉方法(Euler method)作数值积分(numerical integration):
用JavaScript玩转游戏物理(一)运动学模拟与粒子系统
欧拉方法非常简单,但有准确度和稳定性问题,本文会先忽略这些问题。本文的例子采用二维空间,我们先实现一个JavaScript二维矢量类:

// Vector2.js 
Vector2 = function(x, y) { this.x = x; this.y = y; }; Vector2.prototype = { 
copy : function() { return new Vector2(this.x, this.y); }, 
length : function() { return Math.sqrt(this.x * this.x + this.y * this.y); }, 
sqrLength : function() { return this.x * this.x + this.y * this.y; }, 
normalize : function() { var inv = 1/this.length(); return new Vector2(this.x * inv, this.y * inv); }, 
negate : function() { return new Vector2(-this.x, -this.y); }, 
add : function(v) { return new Vector2(this.x + v.x, this.y + v.y); }, 
subtract : function(v) { return new Vector2(this.x - v.x, this.y - v.y); }, 
multiply : function(f) { return new Vector2(this.x * f, this.y * f); }, 
divide : function(f) { var invf = 1/f; return new Vector2(this.x * invf, this.y * invf); }, 
dot : function(v) { return this.x * v.x + this.y * v.y; } 
}; 
Vector2.zero = new Vector2(0, 0);

然后,就可以用HTML5 Canvas去描绘模拟的过程:
var position = new Vector2(10, 200); 
var velocity = new Vector2(50, -50); 
var acceleration = new Vector2(0, 10); 
var dt = 0.1; 
function step() { 
position = position.add(velocity.multiply(dt)); 
velocity = velocity.add(acceleration.multiply(dt)); 
ctx.strokeStyle = "#000000"; 
ctx.fillStyle = "#FFFFFF"; 
ctx.beginPath(); 
ctx.arc(position.x, position.y, 5, 0, Math.PI*2, true); 
ctx.closePath(); 
ctx.fill(); 
ctx.stroke(); 
} 
start("kinematicsCancas", step); <button onclick="eval(document.getElementById('kinematicsCode').value)" type="button">Run</button> 
<button onclick="stop();" type="button">Stop</button> 
<button onclick="clearCanvas();" type="button">Clear</button> 
<table border="0" style="width: 100%;"> 
<tbody> 
<tr> 
<td><canvas id="kinematicsCancas" width="400" height="400"></canvas></td> 
<td width="10"> </td> 
<td width="100%" valign="top"> 
<h4>修改代码试试看</h4> 
<li>改变起始位置</li> 
<li>改变起始速度(包括方向) </li> 
<li>改变加速度</li> 
</td> 
</tr> 
</tbody> 
</table>

这程序的核心就是step()函数头两行代码。很简单吧?
粒子系统
粒子系统(particle system)是图形里常用的特效。粒子系统可应用运动学模拟来做到很多不同的效果。粒子系统在游戏和动画中,常常会用来做雨点、火花、烟、爆炸等等不同的视觉效果。有时候,也会做出一些游戏性相关的功能,例如敌人被打败后会发出一些闪光,主角可以把它们吸收。
粒子的定义
粒子系统模拟大量的粒子,并通常用某些方法把粒子渲染。粒子通常有以下特性:
<li>粒子是独立的,粒子之间互不影响(不碰撞、没有力) </li>
<li>粒子有生命周期,生命结束后会消失</li>
<li>粒子可以理解为空间的一个点,有时候也可以设定半径作为球体和环境碰撞</li>
<li>粒子带有运动状态,也有其他外观状态(例如颜色、影像等) </li>
<li>粒子可以只有线性运动,而不考虑旋转运动(也有例外) </li>

以下是本文例子里实现的粒子类:

// Particle.js 
Particle = function(position, velocity, life, color, size) { 
this.position = position; 
this.velocity = velocity; 
this.acceleration = Vector2.zero; 
this.age = 0; 
this.life = life; 
this.color = color; 
this.size = size; 
};

游戏循环
粒子系统通常可分为三个周期:
发射粒子
模拟粒子(粒子老化、碰撞、运动学模拟等等)
渲染粒子
在游戏循环(game loop)中,需要对每个粒子系统执行以上的三个步骤。
生与死
在本文的例子里,用一个JavaScript数组particles储存所有活的粒子。产生一个粒子只是把它加到数组末端。代码片段如下:
//ParticleSystem.js 
function ParticleSystem() { 
// Private fields 
var that = this; 
var particles = new Array(); 
// Public fields 
this.gravity = new Vector2(0, 100); 
this.effectors = new Array(); 
// Public methods 
this.emit = function(particle) { 
particles.push(particle); 
}; 
// ... 
}

粒子在初始化时,年龄(age)设为零,生命(life)则是固定的。年龄和生命的单位都是秒。每个模拟步,都会把粒子老化,即是把年龄增加<span class="math">\Delta t</span>,年龄超过生命,就会死亡。代码片段如下:
function ParticleSystem() { 
// ... 
this.simulate = function(dt) { 
aging(dt); 
applyGravity(); 
applyEffectors(); 
kinematics(dt); 
}; 
// ... 
// Private methods 
function aging(dt) { 
for (var i = 0; i < particles.length; ) { 
var p = particles[i]; 
p.age += dt; 
if (p.age >= p.life) 
kill(i); 
else 
i++; 
} 
} 
function kill(index) { 
if (particles.length > 1) 
particles[index] = particles[particles.length - 1]; 
particles.pop(); 
} 
// ... 
}

在函数kill()里,用了一个技巧。因为粒子在数组里的次序并不重要,要删除中间一个粒子,只需要复制最末的粒子到那个元素,并用pop()移除最末的粒子就可以。这通常比直接删除数组中间的元素快(在C++中使用数组或std::vector亦是)。
运动学模拟
把本文最重要的两句运动学模拟代码套用至所有粒子就可以。另外,每次模拟会先把引力加速度写入粒子的加速度。这样做是为了将来可以每次改变加速度(续篇会谈这方面)。
function ParticleSystem() { 
// ... 
function applyGravity() { 
for (var i in particles) 
particles[i].acceleration = that.gravity; 
} 
function kinematics(dt) { 
for (var i in particles) { 
var p = particles[i]; 
p.position = p.position.add(p.velocity.multiply(dt)); 
p.velocity = p.velocity.add(p.acceleration.multiply(dt)); 
} 
} 
// ... 
}

渲染
粒子可以用很多不同方式渲染,例如用圆形、线段(当前位置和之前位置)、影像、精灵等等。本文采用圆形,并按年龄生命比来控制圆形的透明度,代码片段如下:
function ParticleSystem() { 
// ... 
this.render = function(ctx) { 
for (var i in particles) { 
var p = particles[i]; 
var alpha = 1 - p.age / p.life; 
ctx.fillStyle = "rgba(" 
+ Math.floor(p.color.r * 255) + "," 
+ Math.floor(p.color.g * 255) + "," 
+ Math.floor(p.color.b * 255) + "," 
+ alpha.toFixed(2) + ")"; 
ctx.beginPath(); 
ctx.arc(p.position.x, p.position.y, p.size, 0, Math.PI * 2, true); 
ctx.closePath(); 
ctx.fill(); 
} 
} 
// ... 
}

基本粒子系统完成
以下的例子里,每帧会发射一个粒子,其位置在画布中间(200,200),发射方向是360度,速率为100,生命为1秒,红色、半径为5象素。
var ps = new ParticleSystem(); 
var dt = 0.01; 
function sampleDirection() { 
var theta = Math.random() * 2 * Math.PI; 
return new Vector2(Math.cos(theta), Math.sin(theta)); 
} 
function step() { 
ps.emit(new Particle(new Vector2(200, 200), sampleDirection().multiply(100), 1, Color.red, 5)); 
ps.simulate(dt); 
clearCanvas(); 
ps.render(ctx); 
} 
start("basicParticleSystemCanvas", step); <button onclick="eval(document.getElementById('basicParticleSystemCode').value)" type="button">Run</button> 
<button onclick="stop();" type="button">Stop</button> 
<table border="0" style="width: 100%;"> 
<tbody> 
<tr> 
<td><canvas id="basicParticleSystemCanvas" width="400" height="400"></canvas></td> 
<td width="10"> </td> 
<td width="100%" valign="top"> 
<h4>修改代码试试看</h4> 
<li>改变发射位置</li> 
<li>向上发射,发射范围在90度内</li> 
<li>改变生命</li> 
<li>改变半径</li> 
<li>每帧发射5个粒子</li> 
</td> 
</tr> 
</tbody> 
</table>

简单碰撞
为了说明用数值积分相对于分析解的优点,本文在粒子系统上加简单的碰撞。我们想加入一个需求,当粒子碰到长方形室(可设为整个Canvas大小)的内壁,就会碰撞反弹,碰撞是完全弹性的(perfectly elastic collision)。
在程序设计上,我把这功能用回调方式进行。 ParticleSystem类有一个effectors数组,在进行运动学模拟之前,先执行每个effectors对象的apply()函数:
而长方形室就这样实现:
// ChamberBox.js 
function ChamberBox(x1, y1, x2, y2) { 
this.apply = function(particle) { 
if (particle.position.x - particle.size < x1 || particle.position.x + particle.size > x2) 
particle.velocity.x = -particle.velocity.x; 
if (particle.position.y - particle.size < y1 || particle.position.y + particle.size > y2) 
particle.velocity.y = -particle.velocity.y; 
}; 
}

这其实就是当侦测到粒子超出内壁的范围,就反转该方向的速度分量。
此外,这例子的主循环不再每次把整个Canvas清空,而是每帧画一个半透明的黑色长方形,就可以模拟动态模糊(motion blur)的效果。粒子的颜色也是随机从两个颜色中取样。
var ps = new ParticleSystem(); 
ps.effectors.push(new ChamberBox(0, 0, 400, 400)); // 最重要是多了这语句 
var dt = 0.01; 
function sampleDirection(angle1, angle2) { 
var t = Math.random(); 
var theta = angle1 * t + angle2 * (1 - t); 
return new Vector2(Math.cos(theta), Math.sin(theta)); 
} 
function sampleColor(color1, color2) { 
var t = Math.random(); 
return color1.multiply(t).add(color2.multiply(1 - t)); 
} 
function step() { 
ps.emit(new Particle(new Vector2(200, 200), sampleDirection(Math.PI * 1.75, Math.PI * 2).multiply(250), 3, sampleColor(Color.blue, Color.purple), 5)); 
ps.simulate(dt); 
ctx.fillStyle="rgba(0, 0, 0, 0.1)"; 
ctx.fillRect(0,0,canvas.width,canvas.height); 
ps.render(ctx); 
} 
start("collisionChamberCanvas", step); <button onclick="eval(document.getElementById('collisionChamberCode').value)" type="button">Run</button> 
<button onclick="stop();" type="button">Stop</button> 
<canvas id="collisionChamberCanvas" width="400" height="400"></canvas>

互动发射
最后一个例子加入互动功能,在鼠标位置发射粒子,粒子方向是按鼠标移动速度再加上一点噪音(noise)。粒子的大小和生命都加入了随机性。
var ps = new ParticleSystem(); 
ps.effectors.push(new ChamberBox(0, 0, 400, 400)); 
var dt = 0.01; 
var oldMousePosition = Vector2.zero, newMousePosition = Vector2.zero; 
function sampleDirection(angle1, angle2) { 
var t = Math.random(); 
var theta = angle1 * t + angle2 * (1 - t); 
return new Vector2(Math.cos(theta), Math.sin(theta)); 
} 
function sampleColor(color1, color2) { 
var t = Math.random(); 
return color1.multiply(t).add(color2.multiply(1 - t)); 
} 
function sampleNumber(value1, value2) { 
var t = Math.random(); 
return value1 * t + value2 * (1 - t); 
} 
function step() { 
var velocity = newMousePosition.subtract(oldMousePosition).multiply(10); 
velocity = velocity.add(sampleDirection(0, Math.PI * 2).multiply(20)); 
var color = sampleColor(Color.red, Color.yellow); 
var life = sampleNumber(1, 2); 
var size = sampleNumber(2, 4); 
ps.emit(new Particle(newMousePosition, velocity, life, color, size)); 
oldMousePosition = newMousePosition; 
ps.simulate(dt); 
ctx.fillStyle="rgba(0, 0, 0, 0.1)"; 
ctx.fillRect(0,0,canvas.width,canvas.height); 
ps.render(ctx); 
} 
start("interactiveEmitCanvas", step); 
canvas.onmousemove = function(e) { 
if (e.layerX || e.layerX == 0) { // Firefox 
e.target.style.position='relative'; 
newMousePosition = new Vector2(e.layerX, e.layerY); 
} 
else 
newMousePosition = new Vector2(e.offsetX, e.offsetY); 
}; 
<button onclick="eval(document.getElementById('interactiveEmitCode').value)" type="button">Run</button> 
<button onclick="stop();" type="button">Stop</button> 
<canvas id="interactiveEmitCanvas" width="400" height="400"></canvas>

总结
本文介绍了最简单的运动学模拟,使用欧拉方法作数值积分,并以此法去实现一个有简单碰撞的粒子系统。本文的精华其实只有两条简单公式(只有两个加数和两个乘数),希望让读者明白,其实物理模拟可以很简单。虽然本文的例子是在二维空间,但这例子能扩展至三维空间,只须把Vector2换成Vector3。本文完整源代码可下载。
续篇会谈及在此基础上加入其他物理现象,有机会再加入其他物理模拟课题。希望各位支持,并给本人更多意见。
Javascript 相关文章推荐
JS中style属性
Oct 11 Javascript
javascript学习笔记(十二) RegExp类型介绍
Jun 20 Javascript
Javascript无参数和有参数类继承问题解决方法
Mar 02 Javascript
javascript结合Canvas 实现简易的圆形时钟
Mar 11 Javascript
jQuery EasyUI Dialog拖不下来如何解决
Sep 28 Javascript
Java中Timer的用法详解
Oct 21 Javascript
轻松实现js图片预览功能
Jan 18 Javascript
理解JavaScript中Promise的使用
Jan 18 Javascript
JS常见疑难点分析之match,charAt,charCodeAt,map,search用法分析
Dec 25 Javascript
JS使用new操作符创建对象的方法分析
May 30 Javascript
Layui点击图片弹框预览的实现方法
Sep 16 Javascript
解决新建一个vue项目过程中遇到的问题
Oct 22 Javascript
一段批量给页面上的控件赋值js
Jun 19 #Javascript
一个简单的js渐显(fadeIn)渐隐(fadeOut)类
Jun 19 #Javascript
高性能WEB开发 flush让页面分块,逐步呈现 flush让页面分块,逐步呈现
Jun 19 #Javascript
WEB高性能开发之疯狂的HTML压缩
Jun 19 #Javascript
Html中JS脚本执行顺序简单举例说明
Jun 19 #Javascript
js parseInt(&quot;08&quot;)未指定进位制问题
Jun 19 #Javascript
ExtJs grid行 右键菜单的两种方法
Jun 19 #Javascript
You might like
转生史莱姆:萌王第一次撸串开心到飞起,哥布塔撸串却神似界王神
2018/11/30 日漫
rrmdir php中递归删除目录及目录下的文件
2011/05/15 PHP
php获取当月最后一天函数分享
2015/02/02 PHP
PHP在linux上执行外部命令的方法
2017/02/06 PHP
PHP实现的迪科斯彻(Dijkstra)最短路径算法实例
2017/09/16 PHP
统计PHP目录中的文件数方法
2019/03/05 PHP
TP5.0框架实现无限极回复功能的方法分析
2019/05/04 PHP
php实现简单四则运算器
2020/11/29 PHP
nginx 设置多个站跨域
2021/03/09 Servers
window.addeventjs事件驱动函数集合addEvent等
2008/02/19 Javascript
jQuery弹出层插件Lightbox_me使用指南
2015/04/21 Javascript
基于AngularJS+HTML+Groovy实现登录功能
2016/02/17 Javascript
jquery实现全选功能效果的实现代码
2016/05/05 Javascript
原生JS:Date对象全面解析
2016/09/06 Javascript
NODE.JS跨域问题的完美解决方案
2016/10/20 Javascript
js运动事件函数详解
2016/10/21 Javascript
mui框架移动开发初体验详解
2017/10/11 Javascript
详解小程序循环require之坑
2019/03/08 Javascript
[02:42]DOTA2城市挑战赛收官在即 四强之争风起云涌
2018/06/05 DOTA
浅谈python在提示符下使用open打开文件失败的原因及解决方法
2018/11/30 Python
python中Mako库实例用法
2020/12/31 Python
利用css3实现的简单的鼠标悬停按钮
2014/11/04 HTML / CSS
用css3实现转换过渡和动画效果
2020/03/13 HTML / CSS
HTML5 video 事件应用示例
2014/09/11 HTML / CSS
马来西亚演唱会订票网站:StubHub马来西亚
2018/10/18 全球购物
Famous Footwear加拿大:美国多品牌运动休闲鞋店
2018/12/05 全球购物
现金会计岗位职责
2013/12/05 职场文书
教师自我评价范文
2013/12/16 职场文书
营业员演讲稿
2013/12/30 职场文书
大学生职业生涯设计书
2014/01/02 职场文书
数学国培研修感言
2014/02/13 职场文书
语文教研活动总结
2014/07/02 职场文书
2014教师党员自我评议(5篇)
2014/09/20 职场文书
小升初自荐信怎么写
2015/03/26 职场文书
MySQL之PXC集群搭建的方法步骤
2021/05/25 MySQL
USB TYPE-C 或将成为所有智能手机充电标准
2022/04/21 数码科技