前言
天地初开,混沌伊始。我们的太阳系随着宇宙大爆炸诞生了。今天就随着作者的思绪一起来探索一下太阳系的奥秘~。嘿嘿,我们就先从最简单的,研究太阳系中各个行星的运动方式开始吧!
我们会先尝试使用HTML + CSS 模拟太阳系的运转,然后再更进一步,深入他们运动的的奥秘的之中。
模拟太阳系
我们可以采用HTML + CSS模拟这样的一个太阳系。这个对于大家来说想必应该很简单了吧???
我们可以很容易的写出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的父子关系进行理解,它们本质上都是一个树形结构)
场景图
意义
其最重要的作用就是为各个结点之间提供了父子关系。
例如在我们的太阳系中:月亮绕着地球运动时,如果地球移动,月亮也会跟着地球一同移动!
所以我们在描述月亮的运动轨迹时,有两种思路可以考虑:
- 仅仅关心月亮绕地球旋转的运动,再根据地球的运动情况计算出月亮相对于太阳的运动轨迹
- 直接计算月亮相对于太阳的运动轨迹。
不难想象,如果没有场景图为我们提供结点之间的父子关系的话,那么我们直接计算两个物体之间的运动关系将会变得十分的复杂!(如下图所示,如果我们直接计算月亮跟太阳之间的运动,这条螺旋线十分的难以计算!)
那么,我们通过场景图又该如何进行计算呢?
场景图计算
首先,我们需要厘清一些基本概念:
结点(Node)、本地坐标系、世界坐标系
我们给Node规定一些基本的属性:
- position: 表示结点的位置(x, y)
- rotation: 表示结点的旋转角度
- scale: 表示结点的缩放大小
- anchor: 表示本地坐标系在结点中的哪个位置,anchor发生改变会影响本地坐标,亦会影响世界坐标。
图中有一个灰色的正方形,其边长为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);
}
效果如下:
小结
本文大致的讲解了 场景图的概念,以及父子之间的位置关系是如何进行影响的,尤其是大家需要对节点的本地坐标与世界坐标有清晰的认识。在讲解场景图的过程中,穿插了一些关于使用JSX语法的知识。这块需要大家自行下载源代码进行梳理。如果有不懂得地方欢迎在文章下方进行留言进行讨论。