用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 相关文章推荐
csdn 批量接受好友邀请
Feb 19 Javascript
一个简单的JavaScript 日期计算算法
Sep 11 Javascript
xss文件页面内容读取(解决)
Nov 28 Javascript
JavaScript Math.ceil() 函数使用介绍
Dec 11 Javascript
用js编写的简单的计算器代码程序
Aug 04 Javascript
浅析js中substring和substr的方法
Nov 09 Javascript
JS 数字转换为大写金额的简单实例
Aug 04 Javascript
JavaScript省市级联下拉菜单实例
Feb 14 Javascript
微信小程序 基础知识css样式media标签
Feb 15 Javascript
值得收藏的vuejs安装教程
Nov 21 Javascript
Auto.js自动收取自己和好友蚂蚁森林能量脚本
Jun 28 Javascript
Vue的watch和computed方法的使用及区别介绍
Sep 06 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与flash as3 socket通信传送文件实现代码
2014/08/16 PHP
memcache一致性hash的php实现方法
2015/03/05 PHP
PHP中使用hidef扩展代替define提高性能
2015/04/09 PHP
详解laravel passport OAuth2.0的4种模式
2019/11/04 PHP
tp5.1 框架数据库高级查询技巧实例总结
2020/05/25 PHP
JavaScript 异步调用框架 (Part 1 - 问题 &amp; 场景)
2009/08/03 Javascript
javascript ie6兼容position:fixed实现思路
2013/04/01 Javascript
JavaScript前补零操作实例
2015/03/11 Javascript
JavaScript实现单击下拉框选择直接跳转页面的方法
2015/07/02 Javascript
jQuery插件Easyui设置datagrid的pageNumber导致两次请求问题的解决方法
2016/08/06 Javascript
关于AngularJs数据的本地存储详解
2017/01/20 Javascript
jQuery中on方法使用注意事项详解
2017/02/15 Javascript
浅析vue component 组件使用
2017/03/06 Javascript
用Nodejs搭建服务器访问html、css、JS等静态资源文件
2017/04/28 NodeJs
JavaScript 值类型和引用类型的初次研究(推荐)
2017/07/19 Javascript
详解Webpack-dev-server的proxy用法
2018/09/08 Javascript
vue根据进入的路由进行原路返回的方法
2018/09/26 Javascript
jquery无缝图片轮播组件封装
2020/11/25 jQuery
微信小程序加载机制及运行机制图解
2019/11/27 Javascript
微信小程序报错: thirdScriptError的错误问题
2020/06/19 Javascript
JavaScript实现缓动动画
2020/11/25 Javascript
[08:54]《一刀刀一天》之DOTA全时刻18:十九支奔赴西雅图队伍全部出炉
2014/06/04 DOTA
[01:15:36]加油刀塔第二期网络版
2014/08/09 DOTA
[36:33]完美世界DOTA2联赛循环赛 Matador vs Forest 第一场 11.06
2020/11/06 DOTA
Python中SOAP项目的介绍及其在web开发中的应用
2015/04/14 Python
python的flask框架难学吗
2020/07/31 Python
使用python画出逻辑斯蒂映射(logistic map)中的分叉图案例
2020/12/11 Python
html5 更新图片颜色示例代码
2014/07/29 HTML / CSS
anello泰国官方网站:日本流行包包品牌
2019/08/08 全球购物
中学生操行评语
2014/04/24 职场文书
大学计划书范文800字
2014/08/14 职场文书
门卫岗位职责
2015/02/09 职场文书
比赛口号霸气押韵
2015/12/24 职场文书
2019垃圾分类宣传口号汇总
2019/08/16 职场文书
SpringBoot整合阿里云视频点播的过程详解
2021/12/06 Java/Android
TV动画《八十龟酱观察日记》第四季宣传PV公布
2022/04/06 日漫