寫在前面
2011年在寫了個物理引擎,期間重新啃起了物理課本,一晃就是5年,
當年自己寫的物理引擎的程式碼又閱讀一遍,受益匪淺,加上最近製作坦克爭霸使用Box2d的思考,對物理引擎管線又有了新的認識和體會。
人除了造人,還可以是造世界,這兩種時候人能夠扮演上帝的角色。有人會說:“幾個小球撞來撞球算哪門子世界?”引用《黑客帝國》裡
男主角的話:“哪一個才是真實的世界?”在小球的眼裡,它的世界就是真實的世界,只是小球無意識,意識形態的程式設計太複雜,
如果有一天意識形態能用程式表達並通過圖靈測試,那麼:"哪一個才是真實的世界?同樣都是原子構成的世界,哪一個才是真實的世界?"。
不廢話,現在就開始吧...
準備工作
執行環境
準備好一款瀏覽器,而且必須是現代瀏覽器(如Google Chrome最新版),因為物理引擎雖然支援老的瀏覽器,但是為了看到這個物理世界發生的一切,會在canvas裡渲染剛體。
頂級NameSpace
為了紀念牛頓,使用Newton作為頂級名稱空間。
var Newton = {};
Class.js
程式碼的分類抽象完全基於Class,使用的class.js如下所示:
var Class = function () { };
Class.extend = function (prop) {
var _super = this.prototype;
var prototype = Object.create(this.prototype);
for (var name in prop) {
prototype[name] = name == "ctor" ?
(function (name, fn) {
return function () {
var tmp = this._super;
this._super = _super[name];
var ret = fn.apply(this, arguments);
this._super = tmp;
return ret;
};
})(name, prop[name]) :
prop[name];
}
function Class() {
this.ctor.apply(this, arguments);
}
Class.prototype = prototype;
Class.prototype._super = Object.create(this.prototype);
Class.prototype.constructor = Class;
Class.extend = arguments.callee;
return Class;
};
向ES6靠齊的class.js,暴露prototype給語言使用者總是不友好的,大概的使用方式如下:
- 通過Class.extend定義類
- 通過XXX.extend實現繼承
- ctor方法裡通過this._super訪問父類ctor
- 其餘方法通過this.xxx訪問父類方法
- 如果本身已經包含xxx方法,通過this._super.xxx訪問父類方法
以前寫過一篇文章介紹。
Vector2
Vector2,一般用來表示向量,有的時候也用來當作點來進行計算。
Newton.Vector2 = Class.extend({
ctor: function (x, y) {
this.x = x;
this.y = y;
},
clone:function() {
return new Newton.Vector2(this.x, this.y);
},
length: function () {
return Math.sqrt(this.x * this.x + this.y * this.y);
},
normalize: function () {
var inv = 1 / this.length();
this.x *= inv;
this.y *= inv;
return this;
},
add: function (v) {
this.x += v.x;
this.y += v.y;
return this;
},
multiply: function (f) {
this.x*=f;
this.y*=f;
return this;
},
dot: function (v) {
return this.x * v.x + this.y * v.y;
},
angle: function (v) {
return Math.acos(this.dot(v) / (this.length() * v.length())) * 180 / Math.PI;
},
distanceSquare: function (x, y) {
return this.x * x + this.y * y;
}
});
其中
- clone複製向量/點
- length求向量長度
- normalize轉單位向量
- add向量疊加
- multiply向量翻倍
- dot內積
- angle方法用來求兩個向量的夾角
- distanceSquare 距離的平方
除了clone方法,其餘方法都不會建立新的Vector2,這裡不能為了使用的程式碼可以連綴而建立大量的Vector2。
知識準備
[角]速度等於加速度在時間上的累加
v = a*t
[角]位移等於速度在時間上的累加
s = v*t
加速度等於過物體重心的力除以質量(最常見的物體受地球的吸引力,即重力。把物體看成質點,而且過重心先不用考慮角速度)
F = ma
運動的獨立性
一個物體同時參與幾種運動,各分運動都可看成獨立進行的,互不影響,物體的合運動則視為幾個相互獨立分運動疊加的結果
如下圖的運動小球:
可拆分成如下三種運動分量:
牛頓的世界
世界裡需要模擬時間流逝,去累加速度、位移。
時間是連續的還是非連續的?到底有沒有最小時間片?最小時間片是多少?現代物理依然無法給出定論。
但是在物理引擎裡,時間是非連續的。
Newton.World = Class.extend({
ctor: function () {
this.bodies = [];
this.bodiesLen = 0;
this.timeStep = 1/60;
}
});
如上面程式碼所示,bodies為世界裡的所有物體,bodiesLen為物體的數量。timeStep為最小時間片段。
時間流逝
Newton.World = Class.extend({
...
...
start: function () {
Newton.Ticker(function () {
this.tick();
this.start();
}.bind(this));
},
tick:function(){
var k = 0;
for (; k < this.bodiesLen ; k++) {
var body = this.bodies[k];
body.tick(this.timeStep);
}
},
add: function (body) {
this.bodies.push(body);
this.bodiesLen = this.bodies.length;
}
...
...
世界可以通過add方法向世界增加物體,上面的tick處理世界上發生的所有事件,目前僅僅是呼叫了物體自身的tick。
ticker程式碼如下:
(function () {
var lastTime = 0;
var Ticker = function (callback, element) {
var currTime = new Date().getTime();
var timeToCall = Math.max(0, 16 - (currTime - lastTime));
var id = window.setTimeout(function () {
callback(currTime + timeToCall);
},
timeToCall);
lastTime = currTime + timeToCall;
return id;
};
Newton.Ticker = Ticker;
}());
這裡不使用requestAnimationFrame的,用的智慧setTimeout。因為tick裡面以後會包含很多邏輯,如重力處理、AABB優化、碰撞檢測、碰撞處理、重疊處理、休眠處理,
requestAnimationFrame裡的函式是在repaint之前呼叫,和複雜且耗時的程式邏輯混在一起會導致幀率下降,起反作用。
第一個物體
小球,是這個世界第一個物體。它除了不分男女,與生俱來許多屬性(運動和碰撞相關的屬性)。
Newton.Circle = Class.extend({
ctor: function (option) {
this.bodyType = Newton.BODYTYPEDYNAMIC;
this.r = option.r;
this.position = new Newton.Vector2(0, 0);
this.mass = 1;
this.linearVelocity = new Newton.Vector2(0, 0);
this.rotation = 0;
this.angularVelocity = 0;
for(var key in option){
if (option.hasOwnProperty(key) && this.hasOwnProperty(key)) {
this[key]=option[key];
}
}
//過重心的力
this.force=Newton.World.Gravity.clone().multiply(this.mass);
if (this.bodyType === Newton.BODYTYPESTATIC) {
this.invMass=0;
}else{
this.invMass=1/this.mass;
}
},
integrateVelocity: function (dt) {
this.linearVelocity.x += this.force.x*this.invMass * dt;
this.linearVelocity.y += this.force.y*this.invMass * dt;
},
integratePosition: function (dt) {
this.position.x += this.linearVelocity.x * dt;
this.position.y += this.linearVelocity.y * dt;
},
integrateRotation: function (dt) {
if (this.rotation >= 360) this.rotation %= 360;
this.rotation += this.angularVelocity * 180 * dt / Math.PI;
},
tick: function (dt) {
this.integrateVelocity(dt);
this.integratePosition(dt);
this.integrateRotation(dt);
}
})
- integrateVelocity對應 v=at
- integratePosition對應 s=mv
- integrateRotation對應 s=mv
- this.force.y*this.invMass對應 a=f/m
上面的建構函式裡,會把傳入的引數覆蓋預設的引數配置,並且提前計算好重力的倒數,因為重力的倒數會被經常用到。
好了。到目前為止已經完成了一款簡陋的物理引擎,包含了物理引擎管線的:重力處理。下面要通過canvas把物理引擎的運作過程視覺化。
渲染準備
Newton.Render = Class.extend({
ctor: function (selector) {
this.canvas = document.querySelector(selector);
this.ctx = this.canvas.getContext("2d");
},
clear: function () {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
},
circle: function (x, y, r, rotation) {
this.ctx.save();
this.ctx.beginPath();
this.ctx.setTransform(1 * Math.cos(rotation), Math.sin(rotation), -1*Math.sin(rotation), Math.cos(rotation), x, y);
this.ctx.arc(0, 0, r, 0, 2 * Math.PI, false);
this.ctx.lineTo(0, 0)
this.ctx.arc(0, 0, 3, 0, 2 * Math.PI, false);
this.ctx.stroke();
this.ctx.restore();
}
});
上面使用setTransform設定變換矩陣,能完成rotate(), scale(), translate(), or transform() 所能完成的工作。
使用下面程式碼測試繪製:
var rd = new Newton.Render("#ourCanvas");
rd.circle(100, 100, 80, 10 * Math.PI / 180);
可以看到下面的效果:
這裡為了能看出旋轉的角度,從圓的重心向右邊r的位置畫了一條線段。因為後續文章當中,也會出現矩形的剛體,同樣,我們可以封裝一個繪製矩形的
方法:
rect: function (x, y, w, h, rotation) {
this.ctx.save();
this.ctx.beginPath();
this.ctx.setTransform(1 * Math.cos(rotation), Math.sin(rotation), -1 * Math.sin(rotation), Math.cos(rotation), x, y);
this.ctx.strokeRect(-w / 2, -h / 2, w, h);
this.ctx.beginPath();
this.ctx.moveTo(w / 2, 0);
this.ctx.lineTo(0, 0);
this.ctx.arc(0, 0, 3, 0, 2 * Math.PI, false);
this.ctx.closePath();
this.ctx.stroke();
this.ctx.restore();
}
讓物理引擎跑起來
var world = new Newton.World();
var c1 = new Newton.Circle({
r: 20,
position: new Newton.Vector2(100, 20),
linearVelocity: new Newton.Vector2(350, 100),
angularVelocity:Math.PI/10
});
world.add(c1);
var render = new Newton.Render("#ourCanvas");
world.onTick(function () {
render.clear();
render.circle(c1.position.x, c1.position.y, c1.r, c1.rotation);
})
world.start();
上面Circle的引數裡面:
- r代表球的半徑
- position是球的位置
- linearVelocity球的線速度
- angularVelocity球的角速度
按照上面一步一步,你將看到一個小球從(100,20 )的位置加速旋轉掉飛下。
第一次重構
因為Newton.Circle 的大部分屬性和方法,在其他的剛體中也適用,只有半徑這個東西是Circle特有的,
所以將Newton.Circle改名為Newton.Body,並移除屬性r,然後Newton.Circle 的程式碼就變成了:
Newton.Circle = Newton.Body.extend({
ctor: function (option) {
this._super(option);
this.r = option.r;
}
});
加四面牆
var world = new Newton.World();
var minV =10;
var c1 = new Newton.Circle({
r: 20,
position: new Newton.Vector2(100, 20),
linearVelocity: new Newton.Vector2(350, 100),
angularVelocity:Math.PI/10
});
world.add(c1);
var render = new Newton.Render("#ourCanvas");
world.onTick(function () {
render.clear();
render.circle(c1.position.x, c1.position.y, c1.r, c1.rotation);
if (c1.position.y - c1.r < 0) {
c1.linearVelocity.y *= -0.95;
c1.angularVelocity *= 0.9;
c1.position.y = c1.r;
}
if (c1.position.y + c1.r > 400 ) {
c1.linearVelocity.y *= -0.95;
c1.angularVelocity *= 0.9;
c1.position.y = 400-c1.r;
}
if (c1.position.x + c1.r > 400) {
c1.linearVelocity.x *= -0.95;
c1.angularVelocity *= 0.9;
c1.position.x = 400 - c1.r;
}
if (c1.position.x - c1.r < 0) {
c1.linearVelocity.x *= -0.95;
c1.angularVelocity *= 0.9;
c1.position.x = c1.r;
}
if (Math.round( c1.position.y+c1.r)===400&& Math.abs(c1.linearVelocity.y) < minV){
c1.linearVelocity.y = 0;
c1.linearVelocity.x *= 0.95;
}
if (Math.abs(c1.linearVelocity.x) < minV) {
c1.linearVelocity.x = 0;
}
})
world.start();
現在你可以看到一個小球在畫布裡,撞來撞去最後靜止。
world.onTick裡面加了一大堆邏輯,用來處理小球與400*400的Canvas的碰撞,以及角速度和角速度的衰減,位置的矯正(重疊處理),到最後
的靜止。因為世界只有圓一種剛體,所有隻能先這樣實現。但是上面的onTick裡新加的程式碼,其實可以窺見物理引擎管線中的必備流程:
- 碰撞檢測
- 碰撞反應
- 重疊處理
- 休眠處理
- ...
與滑鼠互動
...
...
...
function createBall(p){
var c1 = new Newton.Circle({
r: 20,
position: new Newton.Vector2(p.x, p.y),
linearVelocity: new Newton.Vector2(350, 100),
angularVelocity:Math.PI/10
});
world.add(c1);
}
function getMousePos( evt) {
var rect = evt.srcElement.getBoundingClientRect();
return {
x: evt.clientX - rect.left,
y: evt.clientY - rect.top
};
}
var canvas=document.querySelector("#ourCanvas");
canvas.addEventListener("click",function(evt){
createBall(getMousePos(evt));
},false);
var render = new Newton.Render("#ourCanvas");
world.onTick(function () {
render.clear();
for(var i=0;i<world.bodiesLen;i++){
var c1=world.bodies[i];
render.circle(c1.position.x, c1.position.y, c1.r, c1.rotation);
if (c1.position.y - c1.r < 0) {
...
...
...
效果如下:
因為所有的剛體都會被push進world.bodies,所有在onTick中需要遍歷所有的小球進行繪製和與牆面的碰撞檢測。
最後
本篇幅主要做了大量的準備工作包含class.js、ticker.js、vector2.js、render.js,真正的物理引擎的部分只佔了小部分,後續的文章的佔比會恰好相反。
雖然社群裡有許多成熟的物理引擎,但自己實現一款物理引擎有非常多的好處:
- 避開Box2d沉重的計算開銷
- 自由定製和擴充套件自己物理引擎
- 知道每行程式碼的意義使用起來更放心
未完待續..
下篇預告:《零基礎製作物理引擎--創造力量 》