用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 相关文章推荐
JQuery 解析多维的Json数据格式
Nov 02 Javascript
js自定义事件及事件交互原理概述(二)
Feb 01 Javascript
jquery获取html元素的绝对位置和相对位置的方法
Jun 20 Javascript
js实现文字垂直滚动和鼠标悬停效果
Dec 31 Javascript
仿百度换肤功能的简单实例代码
Jul 11 Javascript
利用jquery给指定的table动态添加一行、删除一行的方法
Oct 12 Javascript
基于Phantomjs生成PDF的实现方法
Nov 07 Javascript
微信小程序 自己制作小组件实例详解
Dec 22 Javascript
ES6中Math对象新增的方法实例详解
Apr 25 Javascript
webpack打包js文件及部署的实现方法
Dec 18 Javascript
详解JavaScript基础知识(JSON、Function对象、原型、引用类型)
Jan 16 Javascript
vue 通过base64实现图片下载功能
Dec 19 Vue.js
一段批量给页面上的控件赋值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
PHP4之真OO
2006/10/09 PHP
PHP在线生成二维码(google api)的实现代码详解
2013/06/04 PHP
srcElement表格样式
2006/09/03 Javascript
combox改进版 页面原型参考dojo的,比网上jQuery的那些combox功能强,代码更小
2010/04/15 Javascript
防止按钮在短时间内被多次点击的方法
2014/03/10 Javascript
动态创建script在IE中缓存js文件时导致编码的解决方法
2014/05/04 Javascript
深入分析下javascript中的[]()+!
2015/07/07 Javascript
JS响应鼠标点击实现两个滑块区间拖动效果
2015/10/26 Javascript
让html元素随浏览器的大小自适应垂直居中的实现方法
2016/10/12 Javascript
bootstrap导航条实现代码
2016/12/28 Javascript
详解JavaScript对象的深浅复制
2017/03/30 Javascript
ES6学习教程之对象的扩展详解
2017/05/02 Javascript
jquery.uploadView 实现图片预览上传功能
2017/08/10 jQuery
echarts整合多个类似option的方法实例
2018/07/10 Javascript
解决vue axios的封装 请求状态的错误提示问题
2018/09/25 Javascript
浅谈Vue数据响应
2018/11/05 Javascript
深入学习TypeScript 、React、 Redux和Ant-Design的最佳实践
2019/06/17 Javascript
javascript实现点击星星小游戏
2019/12/24 Javascript
electron+vue实现div contenteditable截图功能
2020/01/07 Javascript
python读写文件操作示例程序
2013/12/02 Python
python获取list下标及其值的简单方法
2016/09/12 Python
关于Python中浮点数精度处理的技巧总结
2017/08/10 Python
python opencv实现旋转矩形框裁减功能
2018/07/25 Python
详解Python 正则表达式模块
2018/11/05 Python
解决Python下imread,imwrite不支持中文的问题
2018/12/05 Python
详解pandas中利用DataFrame对象的.loc[]、.iloc[]方法抽取数据
2020/12/13 Python
如何用python批量调整视频声音
2020/12/22 Python
python Scrapy爬虫框架的使用
2021/01/21 Python
阳光体育:Sunny Sports(购买露营和远足设备)
2018/08/07 全球购物
请解释在new与override的区别
2012/10/29 面试题
淘宝客服工作职责
2014/07/11 职场文书
大学社团招新的通讯稿
2014/09/10 职场文书
2014年纪检监察工作总结
2014/11/11 职场文书
2015年纪检监察工作总结
2015/04/08 职场文书
材料员岗位职责范本
2015/04/11 职场文书
Navicat连接MySQL错误描述分析
2021/06/02 MySQL