aether-engine 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/biome.json +51 -0
- package/bun.lock +192 -0
- package/index.ts +1 -0
- package/package.json +25 -0
- package/serve.ts +125 -0
- package/src/audio/AudioEngine.ts +61 -0
- package/src/components/Animator3D.ts +65 -0
- package/src/components/AudioSource.ts +26 -0
- package/src/components/BitmapText.ts +25 -0
- package/src/components/Camera.ts +33 -0
- package/src/components/CameraFollow.ts +5 -0
- package/src/components/Collider.ts +16 -0
- package/src/components/Components.test.ts +68 -0
- package/src/components/Light.ts +15 -0
- package/src/components/MeshRenderer.ts +58 -0
- package/src/components/ParticleEmitter.ts +59 -0
- package/src/components/RigidBody.ts +9 -0
- package/src/components/ShadowCaster.ts +3 -0
- package/src/components/SkinnedMeshRenderer.ts +25 -0
- package/src/components/SpriteAnimator.ts +42 -0
- package/src/components/SpriteRenderer.ts +26 -0
- package/src/components/Transform.test.ts +39 -0
- package/src/components/Transform.ts +54 -0
- package/src/core/AssetManager.ts +123 -0
- package/src/core/Input.test.ts +67 -0
- package/src/core/Input.ts +94 -0
- package/src/core/Scene.ts +24 -0
- package/src/core/SceneManager.ts +57 -0
- package/src/core/Storage.ts +161 -0
- package/src/desktop/SteamClient.ts +52 -0
- package/src/ecs/System.ts +11 -0
- package/src/ecs/World.test.ts +29 -0
- package/src/ecs/World.ts +149 -0
- package/src/index.ts +115 -0
- package/src/math/Color.ts +100 -0
- package/src/math/Vector2.ts +96 -0
- package/src/math/Vector3.ts +103 -0
- package/src/math/math.test.ts +168 -0
- package/src/renderer/GlowMaterial.ts +66 -0
- package/src/renderer/LitMaterial.ts +337 -0
- package/src/renderer/Material.test.ts +23 -0
- package/src/renderer/Material.ts +80 -0
- package/src/renderer/OcclusionMaterial.ts +43 -0
- package/src/renderer/ParticleMaterial.ts +66 -0
- package/src/renderer/Shader.ts +44 -0
- package/src/renderer/SkinnedLitMaterial.ts +55 -0
- package/src/renderer/WaterMaterial.ts +298 -0
- package/src/renderer/WebGLRenderer.ts +917 -0
- package/src/systems/Animation3DSystem.ts +148 -0
- package/src/systems/AnimationSystem.ts +58 -0
- package/src/systems/AudioSystem.ts +62 -0
- package/src/systems/LightingSystem.ts +114 -0
- package/src/systems/ParticleSystem.ts +278 -0
- package/src/systems/PhysicsSystem.ts +211 -0
- package/src/systems/Systems.test.ts +165 -0
- package/src/systems/TextSystem.ts +153 -0
- package/src/ui/AnimationEditor.tsx +639 -0
- package/src/ui/BottomPanel.tsx +443 -0
- package/src/ui/EntityExplorer.tsx +420 -0
- package/src/ui/GameState.ts +286 -0
- package/src/ui/Icons.tsx +239 -0
- package/src/ui/InventoryPanel.tsx +335 -0
- package/src/ui/PlayerHUD.tsx +250 -0
- package/src/ui/SpriteEditor.tsx +3241 -0
- package/src/ui/SpriteSheetManager.tsx +198 -0
- package/src/utils/GLTFLoader.ts +257 -0
- package/src/utils/ObjLoader.ts +81 -0
- package/src/utils/idb.ts +137 -0
- package/src/utils/packer.ts +85 -0
- package/test_obj.ts +12 -0
- package/tsconfig.json +21 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { System } from "../ecs/System";
|
|
2
|
+
import { Animator3D } from "../components/Animator3D";
|
|
3
|
+
import { Transform } from "../components/Transform";
|
|
4
|
+
import { mat4, quat, vec3 } from "gl-matrix";
|
|
5
|
+
import { GLTFNode } from "../utils/GLTFLoader";
|
|
6
|
+
|
|
7
|
+
export class Animation3DSystem extends System {
|
|
8
|
+
update(dt: number): void {
|
|
9
|
+
const animators = this.world.query(Animator3D);
|
|
10
|
+
|
|
11
|
+
for (const entity of animators) {
|
|
12
|
+
const animator = this.world.getComponent(entity, Animator3D)!;
|
|
13
|
+
const rootTransform = this.world.getComponent(entity, Transform);
|
|
14
|
+
|
|
15
|
+
if (animator.isPlaying && animator.currentAnimation) {
|
|
16
|
+
const anim = animator.animations.get(animator.currentAnimation);
|
|
17
|
+
if (anim) {
|
|
18
|
+
// Advance time
|
|
19
|
+
animator.time += dt * animator.speed;
|
|
20
|
+
|
|
21
|
+
if (animator.time > anim.duration) {
|
|
22
|
+
if (animator.loop) {
|
|
23
|
+
animator.time %= anim.duration;
|
|
24
|
+
} else {
|
|
25
|
+
animator.time = anim.duration;
|
|
26
|
+
animator.isPlaying = false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 1. Evaluate keyframe splines and apply to node Base TRS
|
|
31
|
+
for (const channel of anim.channels) {
|
|
32
|
+
const node = animator.nodes[channel.node];
|
|
33
|
+
if (!node) continue;
|
|
34
|
+
|
|
35
|
+
// Binary search / scan for current timestamp bounds
|
|
36
|
+
let frameIdx = 0;
|
|
37
|
+
for (let i = 0; i < channel.timestamps.length - 1; i++) {
|
|
38
|
+
if (animator.time >= channel.timestamps[i] && animator.time <= channel.timestamps[i + 1]) {
|
|
39
|
+
frameIdx = i;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (frameIdx >= channel.timestamps.length - 1) {
|
|
45
|
+
// Clamp to end
|
|
46
|
+
const lastIdx = channel.timestamps.length - 1;
|
|
47
|
+
if (channel.path === "translation") {
|
|
48
|
+
const offset = lastIdx * 3;
|
|
49
|
+
vec3.set(node.translation, channel.values[offset], channel.values[offset+1], channel.values[offset+2]);
|
|
50
|
+
} else if (channel.path === "rotation") {
|
|
51
|
+
const offset = lastIdx * 4;
|
|
52
|
+
quat.set(node.rotation, channel.values[offset], channel.values[offset+1], channel.values[offset+2], channel.values[offset+3]);
|
|
53
|
+
} else if (channel.path === "scale") {
|
|
54
|
+
const offset = lastIdx * 3;
|
|
55
|
+
vec3.set(node.scale, channel.values[offset], channel.values[offset+1], channel.values[offset+2]);
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Interpolate
|
|
61
|
+
const t0 = channel.timestamps[frameIdx];
|
|
62
|
+
const t1 = channel.timestamps[frameIdx + 1];
|
|
63
|
+
const factor = (animator.time - t0) / (t1 - t0);
|
|
64
|
+
|
|
65
|
+
if (channel.path === "translation") {
|
|
66
|
+
const o1 = frameIdx * 3;
|
|
67
|
+
const o2 = (frameIdx + 1) * 3;
|
|
68
|
+
vec3.lerp(
|
|
69
|
+
node.translation,
|
|
70
|
+
vec3.fromValues(channel.values[o1], channel.values[o1+1], channel.values[o1+2]),
|
|
71
|
+
vec3.fromValues(channel.values[o2], channel.values[o2+1], channel.values[o2+2]),
|
|
72
|
+
factor
|
|
73
|
+
);
|
|
74
|
+
} else if (channel.path === "rotation") {
|
|
75
|
+
const o1 = frameIdx * 4;
|
|
76
|
+
const o2 = (frameIdx + 1) * 4;
|
|
77
|
+
quat.slerp(
|
|
78
|
+
node.rotation,
|
|
79
|
+
quat.fromValues(channel.values[o1], channel.values[o1+1], channel.values[o1+2], channel.values[o1+3]),
|
|
80
|
+
quat.fromValues(channel.values[o2], channel.values[o2+1], channel.values[o2+2], channel.values[o2+3]),
|
|
81
|
+
factor
|
|
82
|
+
);
|
|
83
|
+
} else if (channel.path === "scale") {
|
|
84
|
+
const o1 = frameIdx * 3;
|
|
85
|
+
const o2 = (frameIdx + 1) * 3;
|
|
86
|
+
vec3.lerp(
|
|
87
|
+
node.scale,
|
|
88
|
+
vec3.fromValues(channel.values[o1], channel.values[o1+1], channel.values[o1+2]),
|
|
89
|
+
vec3.fromValues(channel.values[o2], channel.values[o2+1], channel.values[o2+2]),
|
|
90
|
+
factor
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} // End of if (anim)
|
|
95
|
+
} // End of if (isPlaying)
|
|
96
|
+
|
|
97
|
+
// 2. Compute local matrices
|
|
98
|
+
for (let i = 0; i < animator.nodes.length; i++) {
|
|
99
|
+
const node = animator.nodes[i];
|
|
100
|
+
mat4.fromRotationTranslationScale(animator.localTransforms[i], node.rotation, node.translation, node.scale);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 3. Compute global hierarchical matrices
|
|
104
|
+
// Find root nodes (parent === -1)
|
|
105
|
+
const roots = animator.nodes.filter(n => n.parent === -1);
|
|
106
|
+
for (const r of roots) {
|
|
107
|
+
this.traverseHierarchy(r, rootTransform ? rootTransform.matrix : mat4.create(), animator);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 4. Calculate Final Joint Matrices for shader skinning
|
|
111
|
+
// Assume single skin for now
|
|
112
|
+
if (animator.skins.length > 0) {
|
|
113
|
+
const skin = animator.skins[0];
|
|
114
|
+
|
|
115
|
+
for (let j = 0; j < skin.joints.length; j++) {
|
|
116
|
+
const nodeIdx = skin.joints[j];
|
|
117
|
+
const globalMat = animator.nodes[nodeIdx].globalMatrix;
|
|
118
|
+
const ibm = skin.inverseBindMatrices[j];
|
|
119
|
+
|
|
120
|
+
// copy into finalJointMat
|
|
121
|
+
const finalJointMat = mat4.create();
|
|
122
|
+
mat4.multiply(finalJointMat, globalMat, ibm);
|
|
123
|
+
|
|
124
|
+
// If the entity itself has a transform, we must remove it from the final bone calculation because the vertex shader multiplies by uModelMatrix automatically.
|
|
125
|
+
if (rootTransform && rootTransform.matrix) {
|
|
126
|
+
const invModel = mat4.create();
|
|
127
|
+
mat4.invert(invModel, rootTransform.matrix);
|
|
128
|
+
mat4.multiply(finalJointMat, invModel, finalJointMat);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Copy into Float32Array
|
|
132
|
+
for (let f = 0; f < 16; f++) {
|
|
133
|
+
animator.jointMatrices[j * 16 + f] = finalJointMat[f];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private traverseHierarchy(node: GLTFNode, parentGlobalMat: mat4, animator: Animator3D) {
|
|
141
|
+
const localMat = animator.localTransforms[node.id];
|
|
142
|
+
mat4.multiply(node.globalMatrix, parentGlobalMat, localMat);
|
|
143
|
+
|
|
144
|
+
for (const childId of node.children) {
|
|
145
|
+
this.traverseHierarchy(animator.nodes[childId], node.globalMatrix, animator);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { SpriteAnimator } from "../components/SpriteAnimator";
|
|
2
|
+
import { SpriteRenderer } from "../components/SpriteRenderer";
|
|
3
|
+
import { System } from "../ecs/System";
|
|
4
|
+
|
|
5
|
+
export class AnimationSystem extends System {
|
|
6
|
+
update(dt: number) {
|
|
7
|
+
const entities = this.world.query(SpriteRenderer, SpriteAnimator);
|
|
8
|
+
|
|
9
|
+
for (const entity of entities) {
|
|
10
|
+
const renderer = this.world.getComponent(entity, SpriteRenderer)!;
|
|
11
|
+
const animator = this.world.getComponent(entity, SpriteAnimator)!;
|
|
12
|
+
|
|
13
|
+
if (!animator.currentAnimation) continue;
|
|
14
|
+
|
|
15
|
+
const anim = animator.animations.get(animator.currentAnimation);
|
|
16
|
+
if (!anim) continue;
|
|
17
|
+
|
|
18
|
+
if (animator.isPlaying && anim.frames.length > 1) {
|
|
19
|
+
animator.timeAccumulator += dt;
|
|
20
|
+
const frameDuration = 1.0 / anim.fps;
|
|
21
|
+
|
|
22
|
+
while (animator.timeAccumulator >= frameDuration) {
|
|
23
|
+
animator.timeAccumulator -= frameDuration;
|
|
24
|
+
animator.currentFrameIndex =
|
|
25
|
+
(animator.currentFrameIndex + 1) % anim.frames.length;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Update SpriteRenderer UVs
|
|
30
|
+
const frameData = anim.frames[animator.currentFrameIndex];
|
|
31
|
+
|
|
32
|
+
if (animator.atlasFrames && animator.atlasSize) {
|
|
33
|
+
const rect = animator.atlasFrames[frameData];
|
|
34
|
+
renderer.uvScale[0] = rect.w / animator.atlasSize.w;
|
|
35
|
+
renderer.uvScale[1] = rect.h / animator.atlasSize.h;
|
|
36
|
+
|
|
37
|
+
renderer.uvOffset[0] = rect.x / animator.atlasSize.w;
|
|
38
|
+
renderer.uvOffset[1] = rect.y / animator.atlasSize.h;
|
|
39
|
+
} else {
|
|
40
|
+
const col = frameData % animator.columns;
|
|
41
|
+
const row = Math.floor(frameData / animator.columns);
|
|
42
|
+
|
|
43
|
+
const uvW = 1.0 / animator.columns;
|
|
44
|
+
const uvH = 1.0 / animator.rows;
|
|
45
|
+
|
|
46
|
+
renderer.uvScale[0] = uvW;
|
|
47
|
+
renderer.uvScale[1] = uvH;
|
|
48
|
+
renderer.uvOffset[0] = col * uvW;
|
|
49
|
+
renderer.uvOffset[1] = row * uvH;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (animator.flipX) {
|
|
53
|
+
renderer.uvOffset[0] += renderer.uvScale[0];
|
|
54
|
+
renderer.uvScale[0] *= -1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { AudioEngine } from "../audio/AudioEngine";
|
|
2
|
+
import { AudioSource } from "../components/AudioSource";
|
|
3
|
+
import { System } from "../ecs/System";
|
|
4
|
+
// Optional positional tracking with Transform
|
|
5
|
+
// import { Transform } from "../components/Transform";
|
|
6
|
+
|
|
7
|
+
export class AudioSystem extends System {
|
|
8
|
+
update(_dt: number) {
|
|
9
|
+
if (!AudioEngine.hasUserInteracted) return;
|
|
10
|
+
|
|
11
|
+
const entities = this.world.query(AudioSource);
|
|
12
|
+
|
|
13
|
+
for (const entity of entities) {
|
|
14
|
+
const source = this.world.getComponent(entity, AudioSource)!;
|
|
15
|
+
|
|
16
|
+
if (source.shouldPlay && !source.isPlaying) {
|
|
17
|
+
const buffer = AudioEngine.getBuffer(source.bufferName);
|
|
18
|
+
if (buffer) {
|
|
19
|
+
const ctx = AudioEngine.context!;
|
|
20
|
+
if (!ctx) continue;
|
|
21
|
+
|
|
22
|
+
const node = ctx.createBufferSource();
|
|
23
|
+
node.buffer = buffer;
|
|
24
|
+
node.loop = source.loop;
|
|
25
|
+
|
|
26
|
+
const gain = ctx.createGain();
|
|
27
|
+
gain.gain.value = source.volume;
|
|
28
|
+
|
|
29
|
+
node.connect(gain);
|
|
30
|
+
gain.connect(ctx.destination);
|
|
31
|
+
|
|
32
|
+
node.start();
|
|
33
|
+
|
|
34
|
+
source._sourceNode = node;
|
|
35
|
+
source._gainNode = gain;
|
|
36
|
+
source.isPlaying = true;
|
|
37
|
+
|
|
38
|
+
node.onended = () => {
|
|
39
|
+
// If loop is false, the track is over naturally
|
|
40
|
+
source.isPlaying = false;
|
|
41
|
+
source.shouldPlay = false;
|
|
42
|
+
source._sourceNode = undefined;
|
|
43
|
+
source._gainNode = undefined;
|
|
44
|
+
};
|
|
45
|
+
} else {
|
|
46
|
+
console.warn(
|
|
47
|
+
`Audio buffer '${source.bufferName}' not loaded in AudioEngine yet.`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
} else if (!source.shouldPlay && source.isPlaying) {
|
|
51
|
+
source._sourceNode?.stop();
|
|
52
|
+
source.isPlaying = false;
|
|
53
|
+
source._sourceNode = undefined;
|
|
54
|
+
source._gainNode = undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (source._gainNode) {
|
|
58
|
+
source._gainNode.gain.value = source.volume;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Light } from "../components/Light";
|
|
2
|
+
import { ParticleEmitter } from "../components/ParticleEmitter";
|
|
3
|
+
import { Transform } from "../components/Transform";
|
|
4
|
+
import { System } from "../ecs/System";
|
|
5
|
+
|
|
6
|
+
export class LightingSystem extends System {
|
|
7
|
+
// Globally accessible light data for Renderer to bind into uniform bounds
|
|
8
|
+
public static MAX_LIGHTS = 16;
|
|
9
|
+
public static lightPositions = new Float32Array(
|
|
10
|
+
LightingSystem.MAX_LIGHTS * 3,
|
|
11
|
+
);
|
|
12
|
+
public static lightColors = new Float32Array(LightingSystem.MAX_LIGHTS * 3);
|
|
13
|
+
public static lightParams = new Float32Array(LightingSystem.MAX_LIGHTS * 4); // [intensity, radius, elevation, flickerIntensity]
|
|
14
|
+
public static ambientColor = new Float32Array(3);
|
|
15
|
+
public static activePointLights = 0;
|
|
16
|
+
|
|
17
|
+
// High Performance Particle Light track
|
|
18
|
+
public static MAX_PARTICLE_LIGHTS = 64;
|
|
19
|
+
public static activeParticleLights = 0;
|
|
20
|
+
public static particleLightPositions = new Float32Array(LightingSystem.MAX_PARTICLE_LIGHTS * 4);
|
|
21
|
+
public static particleLightColors = new Float32Array(LightingSystem.MAX_PARTICLE_LIGHTS * 3);
|
|
22
|
+
|
|
23
|
+
update(_dt: number) {
|
|
24
|
+
const lights = this.world.query(Transform, Light);
|
|
25
|
+
|
|
26
|
+
LightingSystem.activePointLights = 0;
|
|
27
|
+
LightingSystem.ambientColor[0] = 0;
|
|
28
|
+
LightingSystem.ambientColor[1] = 0;
|
|
29
|
+
LightingSystem.ambientColor[2] = 0;
|
|
30
|
+
|
|
31
|
+
for (const entity of lights) {
|
|
32
|
+
const light = this.world.getComponent(entity, Light)!;
|
|
33
|
+
const transform = this.world.getComponent(entity, Transform)!;
|
|
34
|
+
|
|
35
|
+
if (light.type === "Ambient") {
|
|
36
|
+
LightingSystem.ambientColor[0] += light.color.rNorm * light.intensity;
|
|
37
|
+
LightingSystem.ambientColor[1] += light.color.gNorm * light.intensity;
|
|
38
|
+
LightingSystem.ambientColor[2] += light.color.bNorm * light.intensity;
|
|
39
|
+
} else if (light.type === "Point") {
|
|
40
|
+
if (LightingSystem.activePointLights >= LightingSystem.MAX_LIGHTS)
|
|
41
|
+
continue;
|
|
42
|
+
|
|
43
|
+
const i = LightingSystem.activePointLights;
|
|
44
|
+
transform.updateMatrix();
|
|
45
|
+
|
|
46
|
+
// Get absolute world position from matrix
|
|
47
|
+
const mat = transform.matrix;
|
|
48
|
+
|
|
49
|
+
LightingSystem.lightPositions[i * 3] = mat[12] + light.positionOffset[0];
|
|
50
|
+
LightingSystem.lightPositions[i * 3 + 1] = mat[13] + light.positionOffset[1];
|
|
51
|
+
LightingSystem.lightPositions[i * 3 + 2] = mat[14] + light.elevation + light.positionOffset[2];
|
|
52
|
+
|
|
53
|
+
LightingSystem.lightColors[i * 3] = light.color.rNorm;
|
|
54
|
+
LightingSystem.lightColors[i * 3 + 1] = light.color.gNorm;
|
|
55
|
+
LightingSystem.lightColors[i * 3 + 2] = light.color.bNorm;
|
|
56
|
+
|
|
57
|
+
LightingSystem.lightParams[i * 4] = light.intensity;
|
|
58
|
+
LightingSystem.lightParams[i * 4 + 1] = light.radius;
|
|
59
|
+
LightingSystem.lightParams[i * 4 + 2] = light.elevation;
|
|
60
|
+
LightingSystem.lightParams[i * 4 + 3] = light.flickerIntensity;
|
|
61
|
+
|
|
62
|
+
LightingSystem.activePointLights++;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Process Particle Lights sequentially bounding at limits safely
|
|
67
|
+
LightingSystem.activeParticleLights = 0;
|
|
68
|
+
const emitters = this.world.query(Transform, ParticleEmitter);
|
|
69
|
+
|
|
70
|
+
for (const entity of emitters) {
|
|
71
|
+
if (LightingSystem.activeParticleLights >= LightingSystem.MAX_PARTICLE_LIGHTS) break;
|
|
72
|
+
|
|
73
|
+
const emitter = this.world.getComponent(entity, ParticleEmitter)!;
|
|
74
|
+
const transform = this.world.getComponent(entity, Transform)!;
|
|
75
|
+
transform.updateMatrix();
|
|
76
|
+
const mat = transform.matrix;
|
|
77
|
+
|
|
78
|
+
// We iterate partially mapping a sub-sample of particles to illuminate local pools safely
|
|
79
|
+
for (let p = 0; p < emitter.activeCount; p++) {
|
|
80
|
+
if (LightingSystem.activeParticleLights >= LightingSystem.MAX_PARTICLE_LIGHTS) break;
|
|
81
|
+
|
|
82
|
+
// Standard skip to reduce visual noise by skipping subsets of particles? No, map as many as limit allows!
|
|
83
|
+
const progress = 1.0 - emitter.pLife[p] / emitter.lifespan;
|
|
84
|
+
|
|
85
|
+
// Grab colors
|
|
86
|
+
const r = emitter.startColor.rNorm + (emitter.endColor.rNorm - emitter.startColor.rNorm) * progress;
|
|
87
|
+
const g = emitter.startColor.gNorm + (emitter.endColor.gNorm - emitter.startColor.gNorm) * progress;
|
|
88
|
+
const b = emitter.startColor.bNorm + (emitter.endColor.bNorm - emitter.startColor.bNorm) * progress;
|
|
89
|
+
|
|
90
|
+
// Calculate world pos of particle implicitly
|
|
91
|
+
const px = mat[12] + emitter.pPosX[p];
|
|
92
|
+
const py = mat[13] + emitter.pPosY[p];
|
|
93
|
+
const pz = mat[14] + emitter.pPosZ[p];
|
|
94
|
+
|
|
95
|
+
const idxColor = LightingSystem.activeParticleLights * 3;
|
|
96
|
+
const idxPos = LightingSystem.activeParticleLights * 4;
|
|
97
|
+
LightingSystem.particleLightPositions[idxPos] = px;
|
|
98
|
+
LightingSystem.particleLightPositions[idxPos+1] = py;
|
|
99
|
+
// Fire flies slightly higher than local y, so track Z as actual physical coordinate correctly
|
|
100
|
+
LightingSystem.particleLightPositions[idxPos+2] = pz;
|
|
101
|
+
LightingSystem.particleLightPositions[idxPos+3] = emitter.lightRadius;
|
|
102
|
+
|
|
103
|
+
// Multiply color by dynamic intensity to provide glow that fades slowly IN and formally OUT over life
|
|
104
|
+
const fade = 4.0 * progress * (1.0 - progress);
|
|
105
|
+
const intensity = Math.max(fade, 0.0) * 0.8;
|
|
106
|
+
LightingSystem.particleLightColors[idxColor] = r * intensity;
|
|
107
|
+
LightingSystem.particleLightColors[idxColor+1] = g * intensity;
|
|
108
|
+
LightingSystem.particleLightColors[idxColor+2] = b * intensity;
|
|
109
|
+
|
|
110
|
+
LightingSystem.activeParticleLights++;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { Camera } from "../components/Camera";
|
|
2
|
+
import { MeshRenderer } from "../components/MeshRenderer";
|
|
3
|
+
import { ParticleEmitter } from "../components/ParticleEmitter";
|
|
4
|
+
import { Transform } from "../components/Transform";
|
|
5
|
+
import { System } from "../ecs/System";
|
|
6
|
+
import { ParticleMaterial } from "../renderer/ParticleMaterial";
|
|
7
|
+
|
|
8
|
+
function evalSuperformula(
|
|
9
|
+
m: number,
|
|
10
|
+
n1: number,
|
|
11
|
+
n2: number,
|
|
12
|
+
n3: number,
|
|
13
|
+
a: number,
|
|
14
|
+
b: number,
|
|
15
|
+
phi: number,
|
|
16
|
+
): number {
|
|
17
|
+
let t1 = Math.abs(Math.cos((m * phi) / 4) / a);
|
|
18
|
+
t1 = t1 ** n2;
|
|
19
|
+
let t2 = Math.abs(Math.sin((m * phi) / 4) / b);
|
|
20
|
+
t2 = t2 ** n3;
|
|
21
|
+
const r = (t1 + t2) ** (1 / n1);
|
|
22
|
+
if (Math.abs(r) === 0) {
|
|
23
|
+
return 0;
|
|
24
|
+
} else {
|
|
25
|
+
return 1 / r;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class ParticleSystem extends System {
|
|
30
|
+
update(dt: number) {
|
|
31
|
+
const emitters = this.world.query(Transform, ParticleEmitter);
|
|
32
|
+
|
|
33
|
+
let right = [1, 0, 0];
|
|
34
|
+
let up = [0, 1, 0];
|
|
35
|
+
|
|
36
|
+
const cameras = this.world.query(Transform, Camera);
|
|
37
|
+
if (cameras.length > 0) {
|
|
38
|
+
const camTrans = this.world.getComponent(cameras[0], Transform)!;
|
|
39
|
+
camTrans.updateMatrix();
|
|
40
|
+
right = [camTrans.matrix[0], camTrans.matrix[1], camTrans.matrix[2]];
|
|
41
|
+
up = [camTrans.matrix[4], camTrans.matrix[5], camTrans.matrix[6]];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const entity of emitters) {
|
|
45
|
+
const emitter = this.world.getComponent(entity, ParticleEmitter)!;
|
|
46
|
+
// Lazy initialization of the backing MeshRenderer for drawing
|
|
47
|
+
let mesh = this.world.getComponent(entity, MeshRenderer);
|
|
48
|
+
|
|
49
|
+
if (!mesh) {
|
|
50
|
+
// Pre-calculate full buffers for up to maxParticles
|
|
51
|
+
// 4 vertices per quad, 3 floats per pos, 4 floats per color, 2 per UV
|
|
52
|
+
const positions = new Array(emitter.maxParticles * 4 * 3).fill(0);
|
|
53
|
+
const colors = new Array(emitter.maxParticles * 4 * 4).fill(1);
|
|
54
|
+
const uvs = new Array(emitter.maxParticles * 4 * 2).fill(0);
|
|
55
|
+
const indices = new Array(emitter.maxParticles * 6).fill(0);
|
|
56
|
+
|
|
57
|
+
for (let i = 0; i < emitter.maxParticles; i++) {
|
|
58
|
+
const ic = i * 4;
|
|
59
|
+
// standard quad CCW UVs
|
|
60
|
+
const iv = i * 8;
|
|
61
|
+
uvs[iv] = 0;
|
|
62
|
+
uvs[iv + 1] = 0;
|
|
63
|
+
uvs[iv + 2] = 1;
|
|
64
|
+
uvs[iv + 3] = 0;
|
|
65
|
+
uvs[iv + 4] = 1;
|
|
66
|
+
uvs[iv + 5] = 1;
|
|
67
|
+
uvs[iv + 6] = 0;
|
|
68
|
+
uvs[iv + 7] = 1;
|
|
69
|
+
|
|
70
|
+
const ii = i * 6;
|
|
71
|
+
indices[ii] = ic;
|
|
72
|
+
indices[ii + 1] = ic + 1;
|
|
73
|
+
indices[ii + 2] = ic + 2;
|
|
74
|
+
indices[ii + 3] = ic;
|
|
75
|
+
indices[ii + 4] = ic + 2;
|
|
76
|
+
indices[ii + 5] = ic + 3;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Assign explicit material parameter passing from emitter configurations dynamically tailoring properties
|
|
80
|
+
let targetMat = emitter.material;
|
|
81
|
+
if (!targetMat) {
|
|
82
|
+
targetMat = new ParticleMaterial();
|
|
83
|
+
targetMat.blendMode = emitter.blendMode;
|
|
84
|
+
targetMat.depthWrite = emitter.depthWrite;
|
|
85
|
+
if (targetMat instanceof ParticleMaterial) {
|
|
86
|
+
targetMat.uniforms.uShape = new Int32Array([emitter.shape === "circle" ? 1 : 0]);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
mesh = this.world.addComponent(
|
|
91
|
+
entity,
|
|
92
|
+
MeshRenderer,
|
|
93
|
+
positions,
|
|
94
|
+
undefined,
|
|
95
|
+
colors,
|
|
96
|
+
uvs,
|
|
97
|
+
indices,
|
|
98
|
+
targetMat
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Integration
|
|
103
|
+
const posData = mesh.positions;
|
|
104
|
+
const colData = mesh.colors!;
|
|
105
|
+
|
|
106
|
+
// 1. Spawning
|
|
107
|
+
if (emitter.emissionRate > 0) {
|
|
108
|
+
emitter.emitAccumulator += emitter.emissionRate * dt;
|
|
109
|
+
while (
|
|
110
|
+
emitter.emitAccumulator >= 1.0 &&
|
|
111
|
+
emitter.activeCount < emitter.maxParticles
|
|
112
|
+
) {
|
|
113
|
+
emitter.emitAccumulator -= 1.0;
|
|
114
|
+
// Spawn one
|
|
115
|
+
const i = emitter.activeCount;
|
|
116
|
+
emitter.pLife[i] = emitter.lifespan;
|
|
117
|
+
|
|
118
|
+
const angle = Math.random() * Math.PI * 2;
|
|
119
|
+
const speed =
|
|
120
|
+
emitter.minSpeed +
|
|
121
|
+
Math.random() * (emitter.maxSpeed - emitter.minSpeed);
|
|
122
|
+
|
|
123
|
+
let sfRadius = 1.0;
|
|
124
|
+
if (emitter.useSuperformula) {
|
|
125
|
+
sfRadius = evalSuperformula(
|
|
126
|
+
emitter.superformula.m,
|
|
127
|
+
emitter.superformula.n1,
|
|
128
|
+
emitter.superformula.n2,
|
|
129
|
+
emitter.superformula.n3,
|
|
130
|
+
emitter.superformula.a,
|
|
131
|
+
emitter.superformula.b,
|
|
132
|
+
angle,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (
|
|
137
|
+
emitter.useSuperformula &&
|
|
138
|
+
emitter.superformulaMode === "position"
|
|
139
|
+
) {
|
|
140
|
+
emitter.pPosX[i] = Math.cos(angle) * sfRadius;
|
|
141
|
+
emitter.pPosY[i] = Math.sin(angle) * sfRadius;
|
|
142
|
+
emitter.pPosZ[i] = 0;
|
|
143
|
+
|
|
144
|
+
// In position mode, standard speed projects outwards
|
|
145
|
+
emitter.pVelX[i] = Math.cos(angle) * speed;
|
|
146
|
+
emitter.pVelY[i] = Math.sin(angle) * speed;
|
|
147
|
+
emitter.pVelZ[i] = 0;
|
|
148
|
+
} else {
|
|
149
|
+
emitter.pPosX[i] = 0;
|
|
150
|
+
emitter.pPosY[i] = 0;
|
|
151
|
+
emitter.pPosZ[i] = 0;
|
|
152
|
+
|
|
153
|
+
const appliedSpeed =
|
|
154
|
+
emitter.useSuperformula && emitter.superformulaMode === "velocity"
|
|
155
|
+
? speed * sfRadius
|
|
156
|
+
: speed;
|
|
157
|
+
|
|
158
|
+
emitter.pVelX[i] = Math.cos(angle) * appliedSpeed;
|
|
159
|
+
emitter.pVelY[i] = Math.sin(angle) * appliedSpeed;
|
|
160
|
+
emitter.pVelZ[i] = 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
emitter.activeCount++;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 2. Update logic & construct mesh arrays
|
|
168
|
+
for (let i = 0; i < emitter.activeCount; i++) {
|
|
169
|
+
emitter.pLife[i] -= dt;
|
|
170
|
+
|
|
171
|
+
if (emitter.pLife[i] <= 0) {
|
|
172
|
+
// Swap and pop
|
|
173
|
+
emitter.activeCount--;
|
|
174
|
+
const last = emitter.activeCount;
|
|
175
|
+
if (i !== last) {
|
|
176
|
+
emitter.pLife[i] = emitter.pLife[last];
|
|
177
|
+
emitter.pPosX[i] = emitter.pPosX[last];
|
|
178
|
+
emitter.pPosY[i] = emitter.pPosY[last];
|
|
179
|
+
emitter.pPosZ[i] = emitter.pPosZ[last];
|
|
180
|
+
emitter.pVelX[i] = emitter.pVelX[last];
|
|
181
|
+
emitter.pVelY[i] = emitter.pVelY[last];
|
|
182
|
+
emitter.pVelZ[i] = emitter.pVelZ[last];
|
|
183
|
+
}
|
|
184
|
+
i--; // check swapped item next
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Integration
|
|
189
|
+
emitter.pPosX[i] += emitter.pVelX[i] * dt;
|
|
190
|
+
emitter.pPosY[i] += emitter.pVelY[i] * dt;
|
|
191
|
+
emitter.pPosZ[i] += emitter.pVelZ[i] * dt;
|
|
192
|
+
|
|
193
|
+
// Build quads into Mesh
|
|
194
|
+
const progress = 1.0 - emitter.pLife[i] / emitter.lifespan;
|
|
195
|
+
const size =
|
|
196
|
+
emitter.startSize + (emitter.endSize - emitter.startSize) * progress;
|
|
197
|
+
const hs = size / 2;
|
|
198
|
+
|
|
199
|
+
const px = emitter.pPosX[i];
|
|
200
|
+
const py = emitter.pPosY[i];
|
|
201
|
+
const pz = emitter.pPosZ[i];
|
|
202
|
+
|
|
203
|
+
const baseV = i * 12; // 4 vertices * 3
|
|
204
|
+
|
|
205
|
+
if (emitter.billboard) {
|
|
206
|
+
const rx = right[0] * hs;
|
|
207
|
+
const ry = right[1] * hs;
|
|
208
|
+
const rz = right[2] * hs;
|
|
209
|
+
|
|
210
|
+
const ux = up[0] * hs;
|
|
211
|
+
const uy = up[1] * hs;
|
|
212
|
+
const uz = up[2] * hs;
|
|
213
|
+
|
|
214
|
+
posData[baseV] = px - rx - ux;
|
|
215
|
+
posData[baseV + 1] = py - ry - uy;
|
|
216
|
+
posData[baseV + 2] = pz - rz - uz;
|
|
217
|
+
|
|
218
|
+
posData[baseV + 3] = px + rx - ux;
|
|
219
|
+
posData[baseV + 4] = py + ry - uy;
|
|
220
|
+
posData[baseV + 5] = pz + rz - uz;
|
|
221
|
+
|
|
222
|
+
posData[baseV + 6] = px + rx + ux;
|
|
223
|
+
posData[baseV + 7] = py + ry + uy;
|
|
224
|
+
posData[baseV + 8] = pz + rz + uz;
|
|
225
|
+
|
|
226
|
+
posData[baseV + 9] = px - rx + ux;
|
|
227
|
+
posData[baseV + 10] = py - ry + uy;
|
|
228
|
+
posData[baseV + 11] = pz - rz + uz;
|
|
229
|
+
} else {
|
|
230
|
+
posData[baseV] = px - hs;
|
|
231
|
+
posData[baseV + 1] = py - hs;
|
|
232
|
+
posData[baseV + 2] = pz;
|
|
233
|
+
|
|
234
|
+
posData[baseV + 3] = px + hs;
|
|
235
|
+
posData[baseV + 4] = py - hs;
|
|
236
|
+
posData[baseV + 5] = pz;
|
|
237
|
+
|
|
238
|
+
posData[baseV + 6] = px + hs;
|
|
239
|
+
posData[baseV + 7] = py + hs;
|
|
240
|
+
posData[baseV + 8] = pz;
|
|
241
|
+
|
|
242
|
+
posData[baseV + 9] = px - hs;
|
|
243
|
+
posData[baseV + 10] = py + hs;
|
|
244
|
+
posData[baseV + 11] = pz;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const sr = emitter.startColor.rNorm;
|
|
248
|
+
const sg = emitter.startColor.gNorm;
|
|
249
|
+
const sb = emitter.startColor.bNorm;
|
|
250
|
+
const sa = emitter.startColor.aNorm;
|
|
251
|
+
const er = emitter.endColor.rNorm;
|
|
252
|
+
const eg = emitter.endColor.gNorm;
|
|
253
|
+
const eb = emitter.endColor.bNorm;
|
|
254
|
+
const ea = emitter.endColor.aNorm;
|
|
255
|
+
|
|
256
|
+
const r = sr + (er - sr) * progress;
|
|
257
|
+
const g = sg + (eg - sg) * progress;
|
|
258
|
+
const b = sb + (eb - sb) * progress;
|
|
259
|
+
const a = sa + (ea - sa) * progress;
|
|
260
|
+
|
|
261
|
+
const baseC = i * 16;
|
|
262
|
+
for (let v = 0; v < 4; v++) {
|
|
263
|
+
const cOff = baseC + v * 4;
|
|
264
|
+
colData[cOff] = r;
|
|
265
|
+
colData[cOff + 1] = g;
|
|
266
|
+
colData[cOff + 2] = b;
|
|
267
|
+
colData[cOff + 3] = a;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Update mesh draw count directly to draw only active quads
|
|
272
|
+
mesh.indexCount = emitter.activeCount * 6;
|
|
273
|
+
|
|
274
|
+
// Mark as dirty so WebGLRenderer pushes Float32Array into Buffer array
|
|
275
|
+
mesh.isDirty = true;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|