「新年伊始」我们一起来探索太阳系的奥秘吧

440 阅读8分钟

前言

天地初开,混沌伊始。我们的太阳系随着宇宙大爆炸诞生了。今天就随着作者的思绪一起来探索一下太阳系的奥秘~。嘿嘿,我们就先从最简单的,研究太阳系中各个行星的运动方式开始吧!

我们会先尝试使用HTML + CSS 模拟太阳系的运转,然后再更进一步,深入他们运动的的奥秘的之中。

模拟太阳系

Jan-04-2022 10-42-06.gif

我们可以采用HTML + CSS模拟这样的一个太阳系。这个对于大家来说想必应该很简单了吧???

image.png

我们可以很容易的写出HTML骨架:

<body>
        <div class="solar-system">
            <!-- <div class="sun-orbit">
                
            </div> -->
            <div class="sun"></div>
            <div class="mercury-orbit">
                <div class="mercury"></div>
            </div>
            <div class="venus-orbit">
                <div class="venus"></div>
            </div>
            <div class="earth-orbit">
                <div class="earth">
                    <div class="moon-orbit">
                        <div class="moon"></div>
                    </div>
                </div>
            </div>
        </div>
    </body>

再加入一些简单的CSS动画就完成了我们想要的效果!!!(这里为了方便修改参数,所以在CSS中采用了大量的变量表示)

:root {
    --sun-size: 100px;
    --mercury-orbit-size: 200px;
    --mercury-size: 20px;
    --venus-orbit-size: 300px;
    --venus-size: 30px;
    --earth-orbit-size: 450px;
    --earth-size: 40px;
    --moon-orbit-size: 80px;
    --moon-size: 10px;

}

body {
    width: 100vw;
    height: 100vh;
    transform: translate3d(0);
}

.solar-system {
    position: relative;
    transform: translate(
        calc(100vw / 2 - var(--sun-size) / 2),
        calc(100vh / 2 - var(--sun-size) / 2)
    );
}

.solar-system * {
    position: absolute;
}

.sun {
    width: 100px;
    height: 100px;
    background-image: url(./assets//images/sun.jpeg);
    background-size: 100% 100%;
    border-radius: 50%;
}

.mercury-orbit {
    width: var(--mercury-orbit-size);
    height: var(--mercury-orbit-size);
    left: -50px;
    top: -50px;
    border: 1px solid #ccc;
    border-radius: 50%;
    animation: 2.38s linear 0s infinite reverse both running volvo;
}

.mercury-orbit .mercury {
    width: var(--mercury-size);
    height: var(--mercury-size);
    top: calc(var(--mercury-orbit-size) / 2 - var(--mercury-size) / 2);
    left: calc(var(--mercury-orbit-size) / 2 - var(--mercury-size) / 2);
    border-radius: 50%;
    background-image: url("./assets/images/mercury.png");
    background-size: 100% 100%;
    transform: translate(calc(var(--mercury-orbit-size) / 2));
    animation: 58s linear 0s infinite reverse both running mercury-self-volvo;
}

.venus-orbit {
    width: var(--venus-orbit-size);
    height: var(--venus-orbit-size);
    left: calc((var(--venus-orbit-size) - var(--sun-size)) / -2);
    top: calc((var(--venus-orbit-size) - var(--sun-size)) / -2);
    border: 1px solid #ccc;
    border-radius: 50%;
    animation: 6.14s linear 0s infinite reverse both running volvo;
}

.venus-orbit .venus {
    width: var(--venus-size);
    height: var(--venus-size);
    top: calc(var(--venus-orbit-size) / 2 - var(--venus-size) / 2);
    left: calc(var(--venus-orbit-size) / 2 - var(--venus-size) / 2);
    border-radius: 50%;
    background-image: url("./assets/images/venus.png");
    background-size: 100% 100%;
    transform: translate(calc(var(--venus-orbit-size) / 2));
    animation: 243s linear 0s infinite reverse both running venus-self-volvo;
}
.earth-orbit {
    width: var(--earth-orbit-size);
    height: var(--earth-orbit-size);
    left: calc((var(--earth-orbit-size) - var(--sun-size)) / -2);
    top: calc((var(--earth-orbit-size) - var(--sun-size)) / -2);
    border: 1px solid #ccc;
    border-radius: 50%;
    animation: 10s linear 0s infinite reverse both running volvo;
}

.earth-orbit .earth {
    width: var(--earth-size);
    height: var(--earth-size);
    top: calc(var(--earth-orbit-size) / 2 - var(--earth-size) / 2);
    left: calc(var(--earth-orbit-size) / 2 - var(--earth-size) / 2);
    border-radius: 50%;
    background-image: url("./assets/images/earth.png");
    background-size: 100% 100%;
    transform: translate(calc(var(--earth-orbit-size) / 2));
    animation: 1s linear 0s infinite reverse both running earth-self-volvo;
}

.moon-orbit {
    width: var(--moon-orbit-size);
    height: var(--moon-orbit-size);
    left: calc((var(--moon-orbit-size) - var(--earth-size)) / -2);
    top: calc((var(--moon-orbit-size) - var(--earth-size)) / -2);
    border: 1px solid #ccc;
    border-radius: 50%;
    animation: 10s linear 0s infinite reverse both running volvo;
}

.moon-orbit .moon {
    width: var(--moon-size);
    height: var(--moon-size);
    top: calc(var(--moon-orbit-size) / 2 - var(--moon-size) / 2);
    left: calc(var(--moon-orbit-size) / 2 - var(--moon-size) / 2);
    border-radius: 50%;
    background-image: url("./assets/images/moon.jpeg");
    background-size: 100% 100%;
    transform: translate(calc(var(--moon-orbit-size) / 2));
    /* animation: 1s linear 0s infinite reverse both running earth-self-volvo; */
}
@keyframes volvo {
    from {
        transform: rotate(0);
    }
    to {
        transform: rotate(360deg);
    }
}

@keyframes mercury-self-volvo {
    from {
        transform: translate(calc(var(--mercury-orbit-size) / 2)) rotate(0);
    }
    to {
        transform: translate(calc(var(--mercury-orbit-size) / 2)) rotate(360deg);
    }
}

@keyframes venus-self-volvo {
    from {
        transform: translate(calc(var(--venus-orbit-size) / 2)) rotate(0);
    }
    to {
        transform: translate(calc(var(--venus-orbit-size) / 2)) rotate(-360deg);
    }
}

@keyframes earth-self-volvo {
    from {
        transform: translate(calc(var(--earth-orbit-size) / 2)) rotate(0);
    }
    to {
        transform: translate(calc(var(--earth-orbit-size) / 2)) rotate(-360deg);
    }
}

接下来我们会进入重头戏,我们要探索这太阳系中的奥秘了~

接下来我会给我们绘制的太阳系(以后甚至会绘制银河系、宇宙或者其他的乱七八糟的玩意儿)一个统一的名字:“场景图

事实上,大多数游戏引擎中都有这样的一个概念,ThreeJS中的场景也是如此。场景图具有层级结构(可以对比HTML的父子关系进行理解,它们本质上都是一个树形结构

场景图

意义

其最重要的作用就是为各个结点之间提供了父子关系。

例如在我们的太阳系中:月亮绕着地球运动时,如果地球移动,月亮也会跟着地球一同移动!

所以我们在描述月亮的运动轨迹时,有两种思路可以考虑:

  1. 仅仅关心月亮绕地球旋转的运动,再根据地球的运动情况计算出月亮相对于太阳的运动轨迹
  2. 直接计算月亮相对于太阳的运动轨迹。

不难想象,如果没有场景图为我们提供结点之间的父子关系的话,那么我们直接计算两个物体之间的运动关系将会变得十分的复杂!(如下图所示,如果我们直接计算月亮跟太阳之间的运动,这条螺旋线十分的难以计算!)

Jan-04-2022 10-55-56.gif

那么,我们通过场景图又该如何进行计算呢?

场景图计算

首先,我们需要厘清一些基本概念:

结点(Node)、本地坐标系、世界坐标系

我们给Node规定一些基本的属性:

  • position: 表示结点的位置(x, y)
  • rotation: 表示结点的旋转角度
  • scale: 表示结点的缩放大小
  • anchor: 表示本地坐标系在结点中的哪个位置,anchor发生改变会影响本地坐标,亦会影响世界坐标。

image.png

图中有一个灰色的正方形,其边长为1.它在世界坐标系的中位置是(x, y)。它的anchor位于正中心,那么对于其四个顶点来说:

在本地坐标系中的坐标分别为:(-0.5, 0.5), (0.5, 0.5), (0.5, -0.5), (-0.5, -0.5)

那么在世界坐标系中的坐标需要加上其在世界坐标系中的位置信息(x - 0.5, y + 0.5)...

现在,我们采用矩阵来表示一个Node的空间位置信息(position、rotation、scale),如果你对矩阵如何表示一个物体的位置信息不是很了解的话,可以先参考下面的文章:

WebGL实战篇(四)—— 仿射变换 - 掘金 (juejin.cn)
matrix() - CSS(层叠样式表) | MDN (mozilla.org)

我们先给出场景图中的结点位置的计算方法,后续我们通过实例代码来进一步加深理解:

worldMatrix = parentWorldMatrix * localMatrix;

构建场景图

在构建场景图之前,我们需要先实现我们的绘图框架,我们采用Canvas2D来绘制我们的结点。我们首先来定义我们的Node类


export class SceneNode {
    public x: number = 0;
    public y: number = 0;
    public width: number = 0;
    public height: number = 0;
    public rotation: number = 0;
    public scaleX: number = 1;
    public scaleY: number = 1;
    public color: Color = new Color(0, 0, 0);
    public anchorX: number = 0.5;
    public anchorY: number = 0.5;
    public renderComponent: (ctx: CanvasRenderingContext2D) => void = null;
    private children: SceneNode[] = [];
    private parent: SceneNode = null;

    private localMatrix: mat3 = mat3.create();
    private worldMatrix: mat3 = mat3.create();
    constructor(public name: string = "", public renderNode = false) {}

    addChild(child: SceneNode): void {
        child.remove();
        const index = this.children.indexOf(child);
        if (index < 0) {
            this.children.push(child);
            child.parent = this;
        }
    }

    setParent(parent: SceneNode): void {
        parent.addChild(this);
    }

    removeChild(child: SceneNode) {
        child.parent = null;
        const index = this.children.indexOf(child);
        if (index >= 0) {
            this.children.splice(index, 1);
        }
    }

    remove(): void {
        if (this.parent) {
            this.parent.removeChild(this);
        }
    }

    getWorldMatrix(): mat3 {
        if (!this.parent) {
            return this.getLocalMatrix();
        }
        const out = mat3.create();
        mat3.multiply(out, this.parent.getWorldMatrix(), this.getLocalMatrix());
        return out;
    }

    getLocalMatrix(): mat3 {
        const out = mat3.create();
        mat3.translate(out, out, vec2.fromValues(this.x, this.y));
        mat3.rotate(out, out, angle2Rad(this.rotation));
        mat3.scale(out, out, vec2.fromValues(this.scaleX, this.scaleY));
        return out;
    }

    setAttribute(key: any, value: any): void {
        (this as any)[key] = value;
    }

    visit(iterator: (node: SceneNode) => void): void {
        iterator(this);
        for (let i = 0; i < this.children.length; i++) {
            this.children[i].visit(iterator);
        }
    }

    private conditionVisit(
        condition: (node: SceneNode) => boolean
    ): SceneNode | null {
        const result = condition(this);
        if (result) {
            return this;
        }
        for (let i = 0; i < this.children.length; i++) {
            const result = this.children[i].conditionVisit(condition);
            if (result) {
                return result;
            }
        }
        return null;
    }
    getColor(): string | CanvasGradient | CanvasPattern {
        return `rgba(${this.color.r}, ${this.color.g}, ${this.color.b}, ${this.color.a})`;
    }

    findNodeByName(name: string): SceneNode | null {
        return this.conditionVisit((node) => node.name === name);
    }
}

代码核心

上述代码中的核心在于 getWorldMatrix这个方法,这是一个递归函数,它不断的向上查找父元素,如果没有父元素则返回自己的本地矩阵,如果有父元素,则与父元素的世界矩阵相乘!

得到世界矩阵后,我们可以用(0, 0)[本地坐标]坐标与当前节点的世界矩阵相乘即可得到当前节点的世界坐标!

完善我们的 SceneNode后,我们就可以开始构建我们的场景了,此处为了简单,我们只构建太阳-地球-月球的关系。

我们可以一个一个的 new出一个个的 SceneNode,然后append到对应的节点下。比如下面这样:


const solarSystem = new SceneNode('root');
const sun = new SceneNode('sun');
const earth = new SceneNode('earth');

solarSystem.addChild(sun);
......

但是这样子过于繁琐,此处作者借鉴了JSX的做法,其优点在于可以“声明式”的构建UI。如下:

function EarthOrbit(): SceneNode {
    const earthOrbitRadius = 150;
    const moonOrbitRadius = 50;
    // renderNode=true表示该节点需要被绘制。
    // renderComponent 是指定了绘制的程序
    return (
        <earth-orbit
            rotation={30}
            width={earthOrbitRadius}
            renderComponent={OrbitRenderer}
            color={new Color(100, 100, 255)}
        >
            <earth
                x={earthOrbitRadius}
                renderNode={true}
                width={20}
                color={new Color(10, 128, 255)}
            >
                <moon-orbit
                    width={moonOrbitRadius}
                    rotation={30}
                    renderComponent={OrbitRenderer}
                    color={new Color(100, 100, 100)}
                >
                    <moon
                        renderNode={true}
                        x={moonOrbitRadius}
                        width={10}
                        color={new Color(128, 128, 128)}
                    />
                </moon-orbit>
            </earth>
        </earth-orbit>
    );
}


export function generateScene(): SceneNode {
    return (
        <root x={320} y={240}>
            <sun renderNode={true} width={50} color={new Color(255, 20, 0)} />
            <EarthOrbit />
        </root>
    );
}

这是怎么做到的?
这里采用了 @babel/plugin-transform-react-jsx 这个插件,替换了React.createElement这个API为自定义的API,具体的做法大家可以参考源代码中的配置,此处就不过多展开。

场景定义完毕,现在我们缺少的就是一个渲染程序了,下面我们完善一个简单的渲染程序:


export class Canvas2DRenderer {
    constructor(public ctx: CanvasRenderingContext2D) {}

    render(scene: SceneNode) {
        const ctx = this.ctx;
        ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
        const renderNodes: SceneNode[] = [];
        // 收集渲染节点
        scene.visit((node) => {
            if (node.renderNode || node.renderComponent) {
                renderNodes.push(node);
            }
        });
        
        for (let i = 0; i < renderNodes.length; i++) {
            ctx.beginPath();
            
            const node = renderNodes[i];
            if (node.renderComponent) {
                // 如果有自定义的渲染程序,则走自定义渲染流程。
                node.renderComponent(ctx);
            } else {
                const m = node.getWorldMatrix();
                const pos = vec2.fromValues(0, 0);
                vec2.transformMat3(pos, pos, m);
                ctx.moveTo(pos[0], pos[1]);
                ctx.arc(pos[0], pos[1], node.width, 0, Math.PI * 2);
                ctx.fillStyle = node.getColor();
                ctx.fill();
            }
        }
    }
}

最后,我们用一个主循环来让我们的太阳系动起来!!!~~~

function main() {
    earthOrbit.rotation += 0.5;
    moonOrbit.rotation += 1;
    renderer.render(scene);
    requestAnimationFrame(main);
}

效果如下: Jan-06-2022 14-03-37.gif

小结

本文大致的讲解了 场景图的概念,以及父子之间的位置关系是如何进行影响的,尤其是大家需要对节点的本地坐标与世界坐标有清晰的认识。在讲解场景图的过程中,穿插了一些关于使用JSX语法的知识。这块需要大家自行下载源代码进行梳理。如果有不懂得地方欢迎在文章下方进行留言进行讨论。

点击此处查看源代码: solar-system-example: 场景图 & JSX语法声明场景例子