整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

用JS做个自由落体的球

为用到了一点点物理知识, 我们可以称这为极简化的Javascript物理引擎

大叔惯例,先上效果

下落是个重力加速度的过程

本例通过canvas画布来实现

虽然很简单, 也是先把舞台准备一下

1.准备个HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
 </head>
<body></body>
</html>


2.加个基础样式

    <style>
        * {
            box-sizing: border-box;
            padding: 0;
            margin: 0;
        }
        main {
            width: 100vw;height: 100vh;
            background: hsl(0deg, 0%, 10%);
        }
    </style>

样式的作用是去掉所有元素的外边距、内间距,并把 <main> 元素的宽高设置为与浏览器可视区域相同,背景色为深灰色。

hsl(hue, saturation, brightness) 为 css 颜色表示法之一,参数分别为色相,饱和度和亮度


3.添加 canvas 元素

    <main>
        <canvas id="gamecanvas"></canvas>
    </main>


4.然后就可以用JS来画图了

    const canvas = document.getElementById("gamecanvas");        //通过 canvas 的 id 获取 canvas 元素对象。
    const ctx = canvas.getContext("2d");                        // getContext()  需要一个参数,用于表明是绘制 2d 图像,还是使用 webgl 绘制 3d 图象,这里选择 2d
    canvas.width = window.innerWidth;                           //宽高设置为浏览器可视区域的宽高
    canvas.height = window.innerHeight;
    let _width = canvas.width;
    let _height = canvas.height;
    ctx.fillStyle = "hsl(170, 100%, 50%)";    //给 context 设置颜色
    ctx.beginPath();                                     //开始绘图
    ctx.arc(150, 100, 50, 0, 2 * Math.PI);     //绘制圆形,它接收 5 个参数,前两个为圆心的 x、y 坐标,第 3 个为半径长度, 第 4 个和第 5 个分别是起始角度和结束角度
    ctx.fill();                                                 //给圆形填上颜色

试运行看看

在浏览器指定坐标画图成功

这个时候小球还是处于静止状态, 要让它动起来, 就要通过程序修改它的圆心坐标

让小球移动过程其实就是: 画圆 > 擦除 > 在新坐标1上画圆 > 擦除 > 在新坐标2上画圆...

因为人眼的视觉停留效应, 只要这个过程足够快, 那么在人眼看来这个球就是在做连续的运动而不会看到闪动.

需要达到多快呢?

画圆 > 擦除 > 再画圆 这么一个过程可以看作"一帧"


然后每秒超过24帧就可以, 帧数越高看上去运动就越平滑.

在 JavaScript 中,浏览器提供了 window.requestAnimationFrame() 方法,它接收一个回调函数作为参数,每一次执行回调函数就相当于 1 帧动画,我们需要通过递归或循环连续调用它,浏览器会尽可能地在 1 秒内执行 60 次回调函数。那么利用它,我们就可以对 canvas 进行重绘,以实现小球的移动效果。

基础代码结构看上去的样子:

function drawBall() {
  window.requestAnimationFrame(drawBall);
}
window.requestAnimationFrame(drawBall);

这个drawBall()函数, 就是60次/秒的函数

把刚才的代码重构一下

let x = 150;            //坐标x
let y = 100;            //坐标y
let r = 60;              //半径
function drawBall(now) {
  	ctx.fillStyle = "hsl(170, 100%, 50%)";
  	ctx.beginPath();
  	ctx.arc(x, y, r, 0, 2 * Math.PI);
  	ctx.fill();
    window.requestAnimationFrame(drawBall);
}
window.requestAnimationFrame(drawBall);

计算圆心坐标 x、y 的移动距离,我们需要速度和时间, 速度就是vy, 还需要有时间

window.requestAnimationFrame() 会把当前时间的毫秒数(即时间戳)传递给回调函数,我们可以把本次调用的时间戳保存起来,然后在下一次调用时计算出执行这 1 帧动画消耗了多少秒,然后根据这个秒数和 x、y 轴方向上的速度去计算移动距离,分别加到 x 和 y 上,以获得最新的位置。

改进代码如下

		let x = 100;            //坐标
    let y = 100;
    let r = 60;             //半径
    let vy = 25;            //移动Y轴的速度
    let startTime;
    function drawBall(now) {
        if (!startTime) {
            startTime = now;
        }
        let seconds = (now - startTime) / 1000;
        startTime = now;
        y += vy * seconds;          // 更新Y坐标
        ctx.clearRect(0, 0, width, height);	// 清除画布

        ctx.fillStyle = "hsl(170, 100%, 50%)";
        ctx.beginPath();
        ctx.arc(x, y, r, 0, 2 * Math.PI);
        ctx.fill();

      	window.requestAnimationFrame(drawBall);                  //每次执行完毕后继续调用 window.requestAnimationFrame(process)进行下一次循环
    }
    window.requestAnimationFrame(drawBall);

现在会动了(请忽略一闪而过的鼠标...)

目前只是个匀速运动, 我们要为它加上重力效果

重力加速度的公式: v=gt²

加速度常量g是个恒定值9.8

//先在函数外添加一个常量
const gravity = 9.80;
...
//函数内部, 在计算y坐标前面加一行:
vy += gravity * (seconds^2); // 重力加速度      v=gt²
y += vy * seconds;

这里我们不希望球跑到屏幕外面去, 同时加个边界判断

        //边界检查
        let oy = y + r;        //y+r
        if(oy<height){
            window.requestAnimationFrame(drawBall);                  //每次执行完毕后继续调用 window.requestAnimationFrame(process)进行下一次循环
        }

这样就可以达成文章最开头的运动效果了

很简单, 同时其实也很有意思

比如加个半径变化控制, 就会看到球越往下掉就越小/大

r = r - 0.8;

下面是全部代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="utf-8">
    <style>
        * {
            box-sizing: border-box;
            padding: 0;
            margin: 0;
            font-family: sans-serif;
        }
        main {
            width: 100vw;
            height: 100vh;
            background: hsl(0deg, 0%, 10%);
        }
    </style>
</head>
<body>
    <main>
        <canvas id="gamecanvas"></canvas>
    </main>
</body>
<script>
    const canvas = document.getElementById("gamecanvas");        //通过 canvas 的 id 获取 canvas 元素对象。
    const ctx = canvas.getContext("2d");                        // getContext()  需要一个参数,用于表明是绘制 2d 图像,还是使用 webgl 绘制 3d 图象,这里选择 2d
    canvas.width = window.innerWidth;                           //宽高设置为浏览器可视区域的宽高
    canvas.height = window.innerHeight;
    let width = canvas.width;
    let height = canvas.height;

    const gravity = 9.80;
    let x = 100;            //坐标
    let y = 100;
    let r = 60;             //半径
    let vy = 25;            //移动Y轴的速度
    let startTime;
    function drawBall(now) {
        if (!startTime) {
            startTime = now;
        }
        let seconds = (now - startTime) / 1000;
        startTime = now;
        vy += gravity * (seconds^2); // 重力加速度      v=gt²
        y += vy * seconds;
        r = r - 0.8;

      	ctx.clearRect(0, 0, width, height);
        ctx.fillStyle = "hsl(170, 100%, 50%)";
        ctx.beginPath();
        ctx.arc(x, y, r, 0, 2 * Math.PI);
        ctx.fill();

        //边界检查
        let oy = y + r;
        if(oy<height){
            window.requestAnimationFrame(drawBall);                  //每次执行完毕后继续调用 window.requestAnimationFrame(process)进行下一次循环
        }
    }
    window.requestAnimationFrame(drawBall);
</script>


这期就酱,下期再见[酷拽]

复杂的问题简单化

每次只关注一个知识点

对技术有兴趣的小伙伴可以关注我, 我经常分享各种奇奇怪怪的技术知识

光线跟踪算法里,有一个子问题:如何在一个半径为1的单位球里面,产生一个均匀分布的随机的点(相同的体积里有相同数量的点)。下面这篇文章里给出了5种可能的方法 (参考文献[3])。当然,后面我们会看到,这5种方法并不都是对的。


球里面均匀分布的点


方法一:拒绝掉不在球里的点

这个方法是这样,首先我们在x,y,z分别在 [-1, 1] 里均匀采样,这样就能实现在一个立方体里均匀采样。但这样的点可能不在球里,我们把不在球里的点拒绝掉就行。

function getPoint() {
    var d, x, y, z;
    do {
        x = Math.random() * 2.0 - 1.0;
        y = Math.random() * 2.0 - 1.0;
        z = Math.random() * 2.0 - 1.0;
        d = x*x + y*y + z*z;
    } while(d > 1.0);
    return {x: x, y: y, z: z};
}

这里,Math.random() 会产生一个[0,1]之间的均匀分布的随机数。

方法二:选择一个随机的方向和半径

这个方法选择一个随机的向量,然后把它归一化到一个随机的半径。

function getPoint() {
    var x = Math.random() - 0.5;
    var y = Math.random() - 0.5;
    var z = Math.random() - 0.5;

    var mag = Math.sqrt(x*x + y*y + z*z);
    x /= mag; y /= mag; z /= mag; // 把一个随机的点归一化到单位球面上

    var d = Math.random(); // 一个随机的半径
    return {x: x*d, y: y*d, z: z*d};
}

这个方法会在同样的半径区间里产生同样多的点,比如 半径在 (0.1, 0.2)的区域内产生的点数和半径在 (0.2, 0.3)的区域内的点数,期望是相同的。但是这两个区间的体积却不一样。所以,这个方法并不能产生球内均匀分布的点。

方法三:在球坐标系下选择随机的点

球坐标下,一个点由 r (到原点的距离),theta(和z轴的夹角),phi(向量在x-y平面的投影和x轴的夹角)三个变量控制。这个坐标表示如果表示成笛卡尔坐标系,就是

x = r sin(phi) cos(theta)

y = r sin(phi) sin(theta)

z = r cos(phi)

function getPoint() {
    var theta = Math.random() * 2.0*Math.PI;
    var phi = Math.random() * Math.PI;
    var r = Math.random();
    var sinTheta = Math.sin(theta); 
  	var cosTheta = Math.cos(theta);
    var sinPhi = Math.sin(phi); 
  	var cosPhi = Math.cos(phi);
    var x = r * sinPhi * cosTheta;
    var y = r * sinPhi * sinTheta;
    var z = r * cosPhi;

    return {x: x, y: y, z: z};
}

球体里的一小块体积

根据上图,球体里的一下快体积正比于 sin(phi)d(theta)d(phi) = d(theta) d(cos(phi))。因此,可以看到,如果phi是均匀分布,但是sin(phi)不是均匀分布。

另外,这个算法里,半径r是均匀分布的,但是半径为r的球和单位球的体积比例是r的三次方。也就是说,r越小,点会越密集。

因为这两个原因,这个方法也不符合要求。

方法四:用高斯分布的随机数

我们首先看代码

function getPoint() {
    var u = Math.random();
    var x1 = randn(); // 0为均值,1为方差的高斯分布随机数
    var x2 = randn();
    var x3 = randn();

    var mag = Math.sqrt(x1*x1 + x2*x2 + x3*x3);
    x1 /= mag; x2 /= mag; x3 /= mag;

    var c = Math.cbrt(u); // 立方根

    return {x: x1*c, y:x2*c, z:x3*c};
}

这个地方要对 u 取立方根,是因为半径为u的球的体积是半径为1的球的体积的 u 三次方倍。

这个地方比较奇怪的是,x,y,z为什么要是高斯分布(正态分布)。这个可以参考参考文献[5][6]。

简单解释一下如下:

正态分布的形式是 f(x) = exp{-(x*x)/2} / sqrt(2PI)

因此,f(x, y, z) = exp{ - (x*x + y*y + z*z)/2 } / Const = exp{ - (r*r)/2 } / Const

因此这样产生的点只和它的模长相关,和各种角度都无关。

方法五:改良的球坐标法

在上面的球坐标法中,有2个问题,1. 角度phi均匀分布不代表cos值均匀分布 2. 球的体积和半径的三次方成正比。因此,我们可以直接让cos(phi)值均匀分布,而不是角度均匀分布,同时,对半径开立方根,来保证体积的均匀分布。

function getPoint() {
    var u = Math.random();
    var v = Math.random();
    var theta = u * 2.0 * Math.PI;
    var phi = Math.acos(2.0 * v - 1.0);
    var r = Math.cbrt(Math.random());
    var sinTheta = Math.sin(theta);
    var cosTheta = Math.cos(theta);
    var sinPhi = Math.sin(phi);
    var cosPhi = Math.cos(phi);
    var x = r * sinPhi * cosTheta;
    var y = r * sinPhi * sinTheta;
    var z = r * cosPhi;
    return {x: x, y: y, z: z};
}

参考文献

  1. 各种分布的证明 http://mathworld.wolfram.com/SpherePointPicking.html
  2. 球坐标系的介绍 http://mathworld.wolfram.com/SphericalCoordinates.html
  3. https://karthikkaranth.me/blog/generating-random-points-in-a-sphere/
  4. Marsaglia, G. (1972). Choosing a point from the surface of a sphere,Ann. Math. Statist.,43, 645–646 https://projecteuclid.org/download/pdf_1/euclid.aoms/1177692644
  5. Muller, M. E. (1959). A note on a method for generating points uniformly onN-dimensional spheres,Commun. Ass. Comput. Math. 2, 19–20.
  6. http://extremelearning.com.au/how-to-generate-uniformly-random-points-on-n-spheres-and-n-balls/

ox-decoration-break property,padding-left property,padding-right property,padding-bottom property。