用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 相关文章推荐
Firefox+FireBug使JQuery的学习更加轻松愉快
Jan 01 Javascript
js 实现图片预加载(js操作 Image对象属性complete ,事件onload 异步加载图片)
Mar 25 Javascript
showModelDialog弹出文件下载窗口的使用示例
Nov 19 Javascript
jQuery实现图像旋转动画效果
May 29 Javascript
vue.js入门教程之绑定class和style样式
Sep 02 Javascript
angular仿支付宝密码框输入效果
Mar 25 Javascript
利用Vue v-model实现一个自定义的表单组件
Apr 27 Javascript
javascript按顺序加载运行js方法
Dec 01 Javascript
vue使用 better-scroll的参数和方法详解
Jan 25 Javascript
浅谈vue单一组件下动态修改数据时的全部重渲染
Mar 01 Javascript
angular 服务的单例模式(依赖注入模式下)详解
Oct 22 Javascript
详解nuxt 微信公众号支付遇到的问题与解决
Aug 26 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
php教程之phpize使用方法
2014/02/12 PHP
文件上传之SWFUpload插件(代码)
2015/07/30 PHP
php多线程实现方法及用法实例详解
2015/10/26 PHP
基于php实现随机合并数组并排序(原排序)
2015/11/26 PHP
表单(FORM)的一些实用效果代码
2007/03/25 Javascript
js操作iframe兼容各种主流浏览器示例代码
2013/07/22 Javascript
jquery根据锚点offset值实现动画切换
2014/09/11 Javascript
IE6 hack for js 集锦
2014/09/23 Javascript
JavaScript数组Array对象增加和删除元素方法总结
2015/01/20 Javascript
JavaScript通过字符串调用函数的实现方法
2015/03/18 Javascript
javascript检测两个数组是否相似
2015/05/19 Javascript
node.js操作mysql(增删改查)
2015/07/24 Javascript
巧用Vue.js+Vuex制作专门收藏微信公众号的app
2016/11/03 Javascript
jQuery实现的事件绑定功能基本示例
2017/10/11 jQuery
jQuery实现导航样式布局操作示例【可自定义样式布局】
2018/07/24 jQuery
Vue递归实现树形菜单方法实例
2018/11/06 Javascript
30分钟快速实现小程序语音识别功能
2018/11/27 Javascript
VUE2.0+ElementUI2.0表格el-table实现表头扩展el-tooltip
2018/11/30 Javascript
three.js实现圆柱体
2018/12/30 Javascript
微信公众号服务器验证Token步骤图解
2019/12/30 Javascript
[03:04]DOTA2超级联赛专访ZSMJ “莫名其妙”的逆袭
2013/05/23 DOTA
[02:23]DOTA2英雄基础教程 幻影长矛手
2013/12/09 DOTA
python re模块的高级用法详解
2018/06/06 Python
Python3 JSON编码解码方法详解
2019/09/06 Python
python numpy矩阵信息说明,shape,size,dtype
2020/05/22 Python
python3实现名片管理系统(控制台版)
2020/11/29 Python
AmazeUI 模态窗口的实现代码
2020/08/18 HTML / CSS
乌克兰时尚鞋子和衣服购物网站:Born2be
2018/05/24 全球购物
C#可否对内存进行直接的操作
2015/02/26 面试题
JavaScript获取当前url根目录(路径)
2014/02/19 面试题
求职信写作要突出重点
2014/01/01 职场文书
保卫科工作岗位职责
2014/03/01 职场文书
办理房产过户的委托书
2014/09/14 职场文书
前端学习——JavaScript原生实现购物车案例
2021/03/31 Javascript
微信小程序实现录音Record功能
2021/05/09 Javascript
python 实现图片特效处理
2022/04/03 Python