零基礎製作物理引擎--創造世界

【當耐特】發表於2016-01-06

寫在前面

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

運動的獨立性

一個物體同時參與幾種運動,各分運動都可看成獨立進行的,互不影響,物體的合運動則視為幾個相互獨立分運動疊加的結果

如下圖的運動小球:

usage

可拆分成如下三種運動分量:

usage

牛頓的世界

世界裡需要模擬時間流逝,去累加速度、位移。
時間是連續的還是非連續的?到底有沒有最小時間片?最小時間片是多少?現代物理依然無法給出定論。
但是在物理引擎裡,時間是非連續的。

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);

可以看到下面的效果:

case2

這裡為了能看出旋轉的角度,從圓的重心向右邊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();

現在你可以看到一個小球在畫布裡,撞來撞去最後靜止。

case4

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) {
...
...
... 

效果如下:

case4

因為所有的剛體都會被push進world.bodies,所有在onTick中需要遍歷所有的小球進行繪製和與牆面的碰撞檢測。

最後

本篇幅主要做了大量的準備工作包含class.js、ticker.js、vector2.js、render.js,真正的物理引擎的部分只佔了小部分,後續的文章的佔比會恰好相反。

雖然社群裡有許多成熟的物理引擎,但自己實現一款物理引擎有非常多的好處:

  • 避開Box2d沉重的計算開銷
  • 自由定製和擴充套件自己物理引擎
  • 知道每行程式碼的意義使用起來更放心

未完待續..
下篇預告:《零基礎製作物理引擎--創造力量 》

相關文章