• 零基础制作物理引擎--创造世界


    写在前面

    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沉重的计算开销
    • 自由定制和扩展自己物理引擎
    • 知道每行代码的意义使用起来更放心

    未完待续..
    下篇预告:《零基础制作物理引擎--创造力量 》

  • 相关阅读:
    量化交易,量化分析推荐书单
    韭菜笔记 读《韭菜的自我修养》后感
    Markdown编辑器使用说明
    性能测试工具Locust,一个开源性能测试工具
    Selenium自动化测试,接口自动化测试开发,性能测试从入门到精通
    WebSocket和long poll、ajax轮询的区别,ws协议测试
    docker Dockerfile指令ADD和COPY的区别,添加目录方法
    证券化代币的时代已经到来,STO将引爆区块链经济
    jar包读取jar包内部和外部的配置文件,springboot读取外部配置文件的方法
    android安全检测工具,梆梆安全
  • 原文地址:https://www.cnblogs.com/iamzhanglei/p/5104699.html
Copyright © 2020-2023  润新知