[
{
"texture": "data/textures/particle/UETools/x64/Circle_02.png",
"spawn_rate": 120,
"lifetime": [0.5,0.5],
"size": [0.25,0.25],
"rotation": [0,0],
"spin": [0,0],
"radius": [0,0,0],
"speed": [0.2,0.2],
"direction": [0,1,0],
"cone": 0,
"scale": [[0.005,0.8289938271604937], [0.05,0.8963271604938272], [0.14,0.8959259259259259], [0.3,0.6997001028806585], [0.7200000000000001,0.4099999999999999], [0.98,0.3222222222222222]],
"color": [
{"position":0,"color":[0.9803921568627451,0.023529411764705882,0.8431372549019608,0]},
{"position":0.10869565217391304,"color":[0.9803921568627451,0.023529411764705882,0.8431372549019608,0.8392857142857143]},
{"position":0.33152173913043476,"color":[0.9803921568627451,0.4734827264239028,0.6531979458450047,0.39880952380952384]},
{"position":1,"color":[0.9803921568627451,0.9803921568627451,0.4392156862745098,0]}
]
},
{
"texture": "data/textures/particle/UETools/x64/Star_12.png",
"spawn_rate": 34,
"lifetime": [1,1],
"size": [0.05,0.1],
"rotation": [-3,3],
"spin": [1,2],
"radius": [0.2,0.2,0.2],
"speed": [0,0],
"direction": [0,1,0],
"cone": 0,
"scale": [[0,1], [0.565,1], [0.995,0.9383333333333334]],
"color": [
{"position":0,"color":[0.9882352941176471,0.9490196078431372,0.4549019607843137,0]},
{"position":0.11956521739130435,"color":[0.9882352941176471,0.9490196078431372,0.4549019607843137,0.9880952380952381]},
{"position":0.31521739130434784,"color":[0.9882352941176471,0.9490196078431372,0.4549019607843137,0.9880952380952381]},
{"position":1,"color":[0.9882352941176471,0.9490196078431372,0.4549019607843137,0.1488095238095238]}
]
},
{
"texture": "data/textures/particle/UETools/x64/Star_12.png",
"spawn_rate": 10,
"lifetime": [1.7,2],
"size": [0.05,0.1],
"rotation": [0,0],
"spin": [0,0],
"radius": [0.3,0.3,0.3],
"speed": [0.03,0.2],
"direction": [0,1,0],
"cone": 3,
"scale": [[0.33999999999999997,1.5050000000000001]],
"color": [
{"position":0,"color":[0.9882352941176471,0.7098039215686275,0.011764705882352941,0]},
{"position":0.1902173913043478,"color":[0.9882352941176471,0.3843137254901961,0.6627450980392157,0.6607142857142857]},
{"position":0.5054347826086957,"color":[0.9882352941176471,0.33725490196078434,0.7294117647058823,0.9821428571428571]},
{"position":0.983695652173913,"color":[0.9882352941176471,0.011764705882352941,0.6039215686274509,0]}
]
}
]
[
{
"texture": "data/textures/particle/UETools/x64/Smoke_14.png",
"spawn_rate": 9.3,
"lifetime": [9,9],
"size": [0.15,0.15],
"rotation": [0,6.3],
"spin": [0,0],
"radius": [0,0,0],
"speed": [0.315,0.48500000000000004],
"direction": [0,1,0],
"cone": 0.17481119672686515,
"scale": [[0,2], [0.5,2.6], [1,2.9]],
"color": [
{"position":0,"color":[0.6666666666666666,0.6666666666666666,0.6666666666666666,0]},
{"position":0.25,"color":[0.6666666666666666,0.6666666666666666,0.6666666666666666,0.7]},
{"position":0.5,"color":[0.6666666666666666,0.6666666666666666,0.6666666666666666,0.6]},
{"position":0.75,"color":[0.6666666666666666,0.6666666666666666,0.6666666666666666,0.125]},
{"position":1,"color":[0.6666666666666666,0.6666666666666666,0.6666666666666666,0]}
]
}
]
[
{
"texture": "data/textures/particle/UETools/x64/Stain_04.png",
"spawn_rate": 50,
"lifetime": [0.8,1],
"size": [0.1,0.125],
"rotation": [0,6.283185307179586],
"spin": [0,0],
"radius": [0.5,0.5,0.5],
"speed": [2.2,2.6],
"direction": [0,-1,0],
"cone": 0,
"scale": [[1,1]],
"color": [
{"position":0,"color":[0.5490196078431373,0.40784313725490196,0.30980392156862746,0.8035714285714286]},
{"position":0.09239130434782611,"color":[0.2549019607843137,0.19607843137254902,0.17647058823529413,0.9880952380952381]},
{"position":0.7821620011911852,"color":[0.21568627450980393,0.1803921568627451,0.0784313725490196,0.9880952380952381]},
{"position":1,"color":[0,0,0,0]}
]
},
{
"texture": "data/textures/particle/UETools/x64/Smoke_14.png",
"spawn_rate": 50,
"lifetime": [0.5,0.6],
"size": [0.015,0.025],
"rotation": [0,0],
"spin": [0,0],
"radius": [0.5,0.5,0.5],
"speed": [0.44,0.5],
"direction": [0,1,0],
"cone": 3,
"scale": [[0,1]],
"color": [
{"position":0,"color":[0.596078431372549,0.49019607843137253,0.3058823529411765,0.05952380952380952]},
{"position":0.1793478260869565,"color":[0.6235294117647059,0.592156862745098,0.5294117647058824,0.48214285714285715]},
{"position":0.7119565217391305,"color":[0.5764705882352941,0.5686274509803921,0.5529411764705883,0.34523809523809523]},
{"position":1,"color":[0,0,0,0]}
]
},
{
"texture": "data/textures/particle/UETools/x64/Smoke_08.png",
"spawn_rate": 50,
"lifetime": [2,2],
"size": [0.35,0.35],
"rotation": [0,6.3],
"spin": [-0.1,0.1],
"radius": [0.5,0.5,0.5],
"speed": [-0.5,-0.5],
"direction": [0,1,0],
"cone": 0,
"scale": [[0.2,0.5], [0.62,0.8626041666666667], [0.9750000000000001,0.9834722222222223]],
"color": [
{"position":0,"color":[0.9882352941176471,0.9882352941176471,0.9882352941176471,0.005952380952380952]},
{"position":0.1739130434782609,"color":[0.9882352941176471,0.9137254901960784,0.8196078431372549,0.25595238095238093]},
{"position":1,"color":[0.011764705882352941,0.011764705882352941,0.011764705882352941,0.017857142857142856]}
]
}
]
[
{
"texture": "data/textures/particle/UETools/x64/Light_Beam_04.png",
"spawn_rate": 180,
"lifetime": [0.2,0.2],
"size": [0.175,0.175],
"rotation": [-3,3],
"spin": [0,0],
"radius": [0,0,0],
"speed": [0.15,0.25],
"direction": [0,1,0],
"cone": 6.283185307179586,
"scale": [[0,1.0000284682291665], [0.105,1.0000373125771604], [0.265,0.9483778345360352], [0.41999999999999993,0.8304531540639897], [0.6449999999999999,0.7711138941647975], [0.955,0.6556243239902346]],
"color": [
{"position":0,"color":[0.9882352941176471,0.9882352941176471,0.9882352941176471,0.875]},
{"position":0.043478260869565216,"color":[0.9490196078431372,0.9882352941176471,0.807843137254902,0.8035714285714286]},
{"position":0.16579809410363308,"color":[0.9490196078431372,0.9882352941176471,0.5568627450980392,0.831874810506877]},
{"position":0.42934782608695654,"color":[0.9529411764705882,0.9882352941176471,0.011764705882352941,0.8928571428571429]},
{"position":0.6191185229303157,"color":[0.9254901960784314,0.9882352941176471,0.07058823529411765,0.8630952380952381]},
{"position":1,"color":[0.8549019607843137,0.9882352941176471,0.01568627450980392,0]}
]
},
{
"texture": "data/textures/particle/travnik/plus_1.png",
"spawn_rate": 43,
"lifetime": [0.55,0.7],
"size": [0.025,0.05],
"rotation": [0,0],
"spin": [0,0],
"radius": [0.15,0.15,0.15],
"speed": [0.03,0.1],
"direction": [0,1,0],
"cone": 3,
"scale": [[0.13,0.8566666666666667], [1,1.1319203456790123]],
"color": [
{"position":0.5706521739130435,"color":[0.45098039215686275,0.9882352941176471,0.15294117647058825,0.9821428571428571]},
{"position":0.9836956521739131,"color":[0.45098039215686275,0.9882352941176471,0.15294117647058825,0]}
]
},
{
"texture": "data/textures/particle/UETools/x64/Star_24.png",
"spawn_rate": 23,
"lifetime": [0.17,0.2],
"size": [0.13,0.175],
"rotation": [0,0],
"spin": [0,0],
"radius": [0.3,0.3,0.3],
"speed": [0,0],
"direction": [0,1,0],
"cone": 0,
"scale": [[0.015434782608695685,0.9699871782407408], [0.07782608695652167,1.859251561547733], [0.19,1.9704398895068476], [0.625,1.9227992842084616], [0.7849999999999999,1.673623753579152], [1,1]],
"color": [
{"position":0,"color":[0.9882352941176471,0.9882352941176471,0.9882352941176471,0.6726190476190477]},
{"position":0.10326086956521738,"color":[0.9882352941176471,0.9882352941176471,0.9882352941176471,0.9880952380952381]},
{"position":0.6304347826086957,"color":[0.9882352941176471,0.9882352941176471,0.9882352941176471,0.9620584572947379]},
{"position":0.7381625967837998,"color":[0.9882352941176471,0.9882352941176471,0.9882352941176471,0.5535714285714286]},
{"position":1,"color":[0.9882352941176471,0.9882352941176471,0.9882352941176471,0]}
]
}
]
[
{
"texture": "data/textures/particle/UETools/x64/Star_42.png",
"spawn_rate": 120,
"lifetime": [0.23,0.23],
"size": [0.09,0.115],
"rotation": [-3,3],
"spin": [0,0],
"radius": [0,0,0],
"speed": [0.15,0.25],
"direction": [0,1,0],
"cone": 0.3141592653589793,
"scale": [[0.005,1.0295461111111108], [0.24000000000000002,1.0194342777777776], [0.3799999999999999,0.899218762345679], [0.71,0.8037858445216051], [1,0.6352494305555556]],
"color": [
{"position":0,"color":[0.9882352941176471,0.9882352941176471,0.9882352941176471,0.9880952380952381]},
{"position":0.11956521739130435,"color":[0.8705882352941177,0.9333333333333333,0.9882352941176471,0.9880952380952381]},
{"position":0.2282608695652174,"color":[0.6705882352941176,0.796078431372549,0.9882352941176471,0.9880952380952381]},
{"position":0.4592763549731983,"color":[0.7764705882352941,0.9450980392156862,0.9882352941176471,0.5416666666666666]},
{"position":0.7006402620607505,"color":[0.1450980392156863,0.5372549019607843,0.7568627450980392,0.3333333333333333]},
{"position":1,"color":[0,0,0,0]}
]
},
{
"texture": "data/textures/particle/UETools/x64/Star_12.png",
"spawn_rate": 15,
"lifetime": [1.7,2],
"size": [0.05,0.075],
"rotation": [0,0],
"spin": [0,0],
"radius": [0.01,0.01,0.01],
"speed": [0.05,0.3],
"direction": [0,1,0],
"cone": 3,
"scale": [[0,1], [0.6,1], [1,0.5]],
"color": [
{"position":0,"color":[0.0196078431372549,0.6431372549019608,0.9882352941176471,0]},
{"position":0.21195652173913043,"color":[0.9882352941176471,0.9882352941176471,0.9882352941176471,0.9880952380952381]},
{"position":0.5760869565217391,"color":[0.8470588235294118,0.8470588235294118,0.9882352941176471,0.9880952380952381]},
{"position":0.9836956521739131,"color":[0.396078431372549,0.011764705882352941,0.9882352941176471,0]}
]
},
{
"texture": "data/textures/particle/UETools/x64/Arc_01.png",
"spawn_rate": 80,
"lifetime": [0.6,0.6],
"size": [0.15,0.175],
"rotation": [-3,3],
"spin": [0,0],
"radius": [0,0,0],
"speed": [0,0],
"direction": [0,1,0],
"cone": 0,
"scale": [[0.31,0.41000000000000003]],
"color": [
{"position":0.6684782608695652,"color":[0.5882352941176471,0.803921568627451,0.9686274509803922,0.8630952380952381]},
{"position":0.967391304347826,"color":[0.4196078431372549,0.8549019607843137,0.9686274509803922,0.07142857142857142]}
]
}
]
import { ShadedGeometry } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometry.js";
import { ShadedGeometryFlags } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometryFlags.js";
import { make_plane_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_plane_geometry.js";
import { meshlet_geometry_build_from_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/meshlet_geometry_build_from_geometry.js";
import { StandardAttributes } from "@woosh/meep-engine/src/shade/renderer/geometry/StandardAttributes.js";
import { StandardShadeMaterial } from "@woosh/meep-engine/src/shade/renderer/material/StandardShadeMaterial.js";
import { ShadeImage } from "@woosh/meep-engine/src/shade/renderer/texture/source/ShadeImage.js";
import { ShadeTexture } from "@woosh/meep-engine/src/shade/renderer/texture/ShadeTexture.js";
import { ColorSpace } from "@woosh/meep-engine/src/shade/renderer/texture/ColorSpace.js";
import { TextureWrapType } from "@woosh/meep-engine/src/shade/renderer/texture/TextureWrapType.js";
// ─── Procedural ground texture ──────────────────────────────────────────────
// Olive grid: minor lines every 2 world units, major lines every 8.
const GROUND_MINOR_WORLD_UNITS = 2;
const GROUND_MAJOR_WORLD_UNITS = 8;
const GROUND_MINOR_PER_TILE = 8;
const GROUND_TILE_WORLD_UNITS = GROUND_MINOR_PER_TILE * GROUND_MINOR_WORLD_UNITS;
const MAJOR_STEP_CELLS = GROUND_MAJOR_WORLD_UNITS / GROUND_MINOR_WORLD_UNITS;
// Shade has no canvas-backed texture type of its own: a 2D canvas becomes an
// `ImageBitmap`, the bitmap becomes a `ShadeImage` (the pixels), and the image
// becomes a `ShadeTexture` (how to sample them). The canvas paints in sRGB, so
// say so — the renderer works in linear light and decodes on the GPU.
async function makeGroundTexture() {
const size = 256;
const cellPx = size / GROUND_MINOR_PER_TILE;
const canvas = document.createElement("canvas");
canvas.width = canvas.height = size;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#3c4838";
ctx.fillRect(0, 0, size, size);
function crosshatch(positions, lineWidth, color) {
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
for (const p of positions) {
ctx.beginPath();
ctx.moveTo(p, 0); ctx.lineTo(p, size);
ctx.moveTo(0, p); ctx.lineTo(size, p);
ctx.stroke();
}
}
const minorPositions = [];
for (let i = 1; i < GROUND_MINOR_PER_TILE; i++) {
if (i % MAJOR_STEP_CELLS === 0) continue;
minorPositions.push(Math.floor(i * cellPx) + 0.5);
}
crosshatch(minorPositions, 1, "rgba(255, 255, 255, 0.09)");
const majorPositions = [];
for (let i = 0; i < GROUND_MINOR_PER_TILE; i += MAJOR_STEP_CELLS) {
majorPositions.push(i === 0 ? 1.5 : Math.floor(i * cellPx) + 0.5);
}
crosshatch(majorPositions, 1.5, "rgba(255, 255, 255, 0.20)");
const image = ShadeImage.fromImageBitmap(await createImageBitmap(canvas));
image.color_space = ColorSpace.SRGB;
const texture = ShadeTexture.from(image);
texture.wrapS = texture.wrapT = TextureWrapType.Repeat;
// Mipmapped trilinear + 16x anisotropy is what a `ShadeTexture` asks for by
// default, and it is what this wants: the ground runs away from the camera at
// a grazing angle, so minification is the case that matters.
return texture;
}
// Lay the XY plane flat and tile through its UV coordinates.
function makeGroundGeometry(worldSize, tiles) {
const geometry = make_plane_geometry(worldSize, worldSize);
// (x, y, z) → (x, z, −y): the +Z normal becomes +Y and the winding is
// preserved, so the lit side faces up. Back faces are always culled.
for (const name of [StandardAttributes.Position, StandardAttributes.Normal]) {
const data = geometry.getAttribute(name).data;
for (let i = 0; i < data.length; i += 3) {
const y = data[i + 1];
data[i + 1] = data[i + 2];
data[i + 2] = -y;
}
}
const uv = geometry.getAttribute(StandardAttributes.TextureCoordinates0).data;
for (let i = 0; i < uv.length; i++) {
uv[i] *= tiles;
}
geometry.computeBoundingBox();
return geometry;
}
// Call after engine bootstrap, which initializes meshoptimizer.
export async function makeGround(worldSize = 60) {
const groundGeometry = meshlet_geometry_build_from_geometry(
makeGroundGeometry(worldSize, worldSize / GROUND_TILE_WORLD_UNITS),
);
const groundMaterial = new StandardShadeMaterial();
groundMaterial.texture_albedo = await makeGroundTexture();
groundMaterial.roughness_factor = 1;
groundMaterial.metallic_factor = 0;
const groundSG = ShadedGeometry.from(groundGeometry, groundMaterial);
groundSG.clearFlag(ShadedGeometryFlags.CastShadow);
groundSG.setFlag(ShadedGeometryFlags.ReceiveShadow);
return groundSG;
}
import { OrbitingBehavior } from "@woosh/meep-engine/src/engine/intelligence/behavior/ecs/OrbitingBehavior.js";
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import { Light } from "@woosh/meep-engine/src/engine/graphics/ecs/light/Light.js";
import { LightType } from "@woosh/meep-engine/src/engine/graphics/ecs/light/LightType.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import Vector3 from "@woosh/meep-engine/src/core/geom/Vector3.js";
import { ShadedGeometrySystem } from "@woosh/meep-engine/src/engine/graphics3/ShadedGeometrySystem.js";
import { ParticleEffect } from "@woosh/meep-engine/src/engine/graphics/ecs/particles/ParticleEffect.js";
import { GPUParticleEmitterSystem } from "@woosh/meep-engine/src/engine/graphics3/GPUParticleEmitterSystem.js";
import { create_particle_effect } from "@woosh/meep-engine/src/shade/renderer/particles/runtime/create_particle_effect.js";
import { buildParticleGraphs } from "./particleGraphs.js";
import { makeGround } from "./ground.js";
import { BehaviorComponent } from "@woosh/meep-engine/src/engine/intelligence/behavior/ecs/BehaviorComponent.js";
import { BehaviorSystem } from "@woosh/meep-engine/src/engine/intelligence/behavior/ecs/BehaviorSystem.js";
import buffProjectile from "./specs/buff-projectile.json";
import magicAttackProjectile from "./specs/magic-attack-projectile.json";
import healingProjectile from "./specs/healing-projectile.json";
import earthProjectile from "./specs/earth-projectile.json";
import chimneySmokeLight from "./specs/chimney-smoke-light.json";
// ─── Scene parameters ───────────────────────────────────────────────────────
// Five effects in a row along X, hovering just above the ground.
// `period` is the duration of one full orbit in seconds.
const EFFECTS = [
{ layers: buffProjectile, x: -6, y: 1.0, z: 0, period: 1.1113 },
{ layers: magicAttackProjectile, x: -3, y: 1.0, z: 0, period: 1.237 },
{ layers: healingProjectile, x: 0, y: 1.0, z: 0, period: 1.391 },
{ layers: earthProjectile, x: 3, y: 1.0, z: 0, period: 1.6 },
{ layers: chimneySmokeLight, x: 6, y: 0.5, z: 0, period: 4 },
];
// ─── Bootstrap ──────────────────────────────────────────────────────────────
const engine = await EngineHarness.bootstrap({
configuration: (config, engine) => {
config.addSystem(new ShadedGeometrySystem(engine.graphics, EngineHarness.shadeScene(engine)));
config.addSystem(new GPUParticleEmitterSystem(engine.graphics, EngineHarness.shadeScene(engine), engine.assetManager));
config.addSystem(new BehaviorSystem(engine));
},
});
await EngineHarness.buildBasics({
engine,
enableTerrain: false,
enableWater: false,
enableLights: true,
enableShadows: false,
focus: new Vector3(0, 1, 0),
distance: 14,
pitch: 0.6,
yaw: 0.4,
cameraFieldOfView: 45,
cameraFarDistance: 200,
// default orbital + keyboard controller — drag to orbit, WASD to pan,
// wheel to zoom.
showFps: false,
});
// Use fixed exposure so the authored colours stay consistent.
engine.graphics.renderer.feature_automatic_exposure_enabled = false;
engine.entityManager.dataset.traverseEntities([Light], (light) => {
if (light.type.getValue() === LightType.DIRECTION) {
light.intensity.set(6);
}
});
const ecd = engine.entityManager.dataset;
new Entity().add(new Transform64()).add(await makeGround()).build(ecd);
// ─── Spawn the emitters ─────────────────────────────────────────────────────
for (const [index, def] of EFFECTS.entries()) {
// A component has one texture. The layers of an effect share an orbit, but
// each has its own compiled simulation program and ParticleEffect entity.
for (const layer of def.layers) {
const effect = ParticleEffect.from({
...create_particle_effect(buildParticleGraphs(layer)),
texture: layer.texture,
spawn_rate: layer.spawn_rate,
flags: { soft_depth: true },
render: { position: "position", size: "size", color: "color", rotation: "rotation" },
});
const orbit = OrbitingBehavior.from({
center: new Vector3(def.x, def.y, def.z),
radius: 0.6,
rate: (2 * Math.PI) / def.period,
});
orbit.phase = (index / EFFECTS.length) * Math.PI * 2;
new Entity()
.add(Transform64.fromJSON({ translation: { x: def.x, y: def.y, z: def.z } }))
.add(effect)
.add(BehaviorComponent.from(orbit))
.build(ecd);
}
}
// ─── HUD: FPS readout ──────────────────────────────────────────────────────
const fpsEl = document.getElementById("fps");
let fpsWindow = 0;
let fpsFrames = 0;
let lastFrame = performance.now();
engine.graphics.on.postRender.add(() => {
const now = performance.now();
const dt = (now - lastFrame) / 1000;
lastFrame = now;
fpsWindow += dt;
fpsFrames++;
if (fpsWindow >= 0.5) {
fpsEl.textContent = (fpsFrames / fpsWindow).toFixed(0);
fpsWindow = 0;
fpsFrames = 0;
}
});
import { NodeGraph } from "@woosh/meep-engine/src/core/model/node-graph/NodeGraph.js";
import { ParticleLayout } from "@woosh/meep-engine/src/shade/renderer/particles/layout/ParticleLayout.js";
import { particle_node, particle_wire } from "@woosh/meep-engine/src/shade/renderer/particles/graph/particle_graph_authoring.js";
import { particle_unit_sphere } from "@woosh/meep-engine/src/shade/renderer/particles/graph/particle_graph_distributions.js";
import { VM_BUILTIN } from "@woosh/meep-engine/src/shade/renderer/particles/isa/ParticleVMISA.js";
// All per-particle state is explicit. Size is a billboard half-extent in metres.
export const particleLayout = new ParticleLayout([
{ name: "position", components: 3 },
{ name: "velocity", components: 3 },
{ name: "age", components: 1 },
{ name: "lifetime", components: 1 },
{ name: "baseSize", components: 1 },
{ name: "size", components: 1 },
{ name: "rotation", components: 1 },
{ name: "spin", components: 1 },
{ name: "color", components: 4, type: "color" },
]);
// A small wiring shorthand: sources are node IDs, or arrays for literal values.
function node(graph, type, inputs = {}, parameters = {}) {
const id = particle_node(graph, type, parameters);
for (const [port, source] of Object.entries(inputs)) {
particle_wire(graph, id, port, source);
}
return id;
}
const read = (graph, name) => node(graph, "attribute", {}, { name });
const write = (graph, name, value) => node(graph, "setAttribute", { value }, { name });
function randomRange(graph, [min, max]) {
if (min === max) return [min];
const random = node(graph, "random", {}, { components: 1 });
return node(graph, "mad", { a: random, b: [max - min], c: [min] });
}
// Uniform spherical cap about +Y or -Y, the two axes used by these effects.
// Retain the authored angle, including 2π: cos(2π) collapses that cap to its axis.
function velocity(graph, layer) {
if (layer.cone === 0) {
return node(graph, "scale", { v: layer.direction, s: randomRange(graph, layer.speed) });
}
const height = randomRange(graph, [Math.cos(layer.cone), 1]);
const angle = randomRange(graph, [0, Math.PI * 2]);
const squared = node(graph, "mul", { a: height, b: height });
const radius = node(graph, "sqrt", {
x: node(graph, "max", { a: node(graph, "sub", { a: [1], b: squared }), b: [0] }),
});
const direction = node(graph, "vec3", {
x: node(graph, "mul", { a: radius, b: node(graph, "cos", { x: angle }) }),
y: node(graph, "mul", { a: height, b: [layer.direction[1]] }),
z: node(graph, "mul", { a: radius, b: node(graph, "sin", { x: angle }) }),
});
return node(graph, "scale", { v: direction, s: randomRange(graph, layer.speed) });
}
// Matching incoming/outgoing segment slopes makes the Hermite curve piecewise linear.
function scaleCurve(stops) {
const slope = (a, b) => (b[1] - a[1]) / (b[0] - a[0]);
return { keys: stops.map(([time, value], i) => ({
time, value,
inTangent: i === 0 ? 0 : slope(stops[i - 1], stops[i]),
outTangent: i === stops.length - 1 ? 0 : slope(stops[i], stops[i + 1]),
})) };
}
function appearance(graph, layer, normalizedAge, baseSize) {
const scale = node(graph, "curveLiteral", { t: normalizedAge }, { curve: scaleCurve(layer.scale) });
write(graph, "size", node(graph, "mul", { a: baseSize, b: scale }));
write(graph, "color", node(graph, "gradientRGBA", { t: normalizedAge }, { stops: layer.color }));
}
/** Build inspectable INIT and UPDATE graphs for one textured layer. */
export function buildParticleGraphs(layer) {
const init = new NodeGraph();
const origin = node(init, "builtin", {}, { id: VM_BUILTIN.EMITTER_POSITION });
let position = origin;
if (layer.radius.some(radius => radius !== 0)) {
// Cube-root radius distributes points uniformly by volume, not by radius.
const radius = node(init, "pow", { a: randomRange(init, [0, 1]), b: [1 / 3] });
const sphere = node(init, "scale", { v: particle_unit_sphere(init), s: radius });
const offset = node(init, "mul", { a: sphere, b: layer.radius });
position = node(init, "add", { a: origin, b: offset });
}
write(init, "position", position);
write(init, "velocity", velocity(init, layer));
write(init, "age", [0]);
write(init, "lifetime", randomRange(init, layer.lifetime));
write(init, "rotation", randomRange(init, layer.rotation));
write(init, "spin", randomRange(init, layer.spin));
const baseSize = randomRange(init, layer.size);
write(init, "baseSize", baseSize);
appearance(init, layer, [0], baseSize);
const update = new NodeGraph();
const dt = node(update, "builtin", {}, { id: VM_BUILTIN.DELTA_TIME });
const age = node(update, "add", { a: read(update, "age"), b: dt });
write(update, "age", age);
const normalizedAge = node(update, "div", { a: age, b: read(update, "lifetime") });
const expired = node(update, "compare", { a: normalizedAge, b: [1] }, { op: "ge" });
node(update, "kill", { condition: expired });
// World-space state: old particles stay behind as the emitting entity orbits.
const displacement = node(update, "scale", { v: read(update, "velocity"), s: dt });
write(update, "position", node(update, "add", { a: read(update, "position"), b: displacement }));
const turn = node(update, "mul", { a: read(update, "spin"), b: dt });
write(update, "rotation", node(update, "add", { a: read(update, "rotation"), b: turn }));
appearance(update, layer, normalizedAge, read(update, "baseSize"));
return { layout: particleLayout, init, update };
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Particle effects · Meep</title>
<meta name="robots" content="noindex">
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
width: 100%; height: 100%;
overflow: hidden;
background: #07090c;
color: #e6edf3;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.panel {
position: fixed;
z-index: 100;
background: rgba(7, 9, 12, 0.72);
border: 1px solid #1f2731;
border-radius: 10px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 12px 32px rgba(0,0,0,0.4);
}
.hud {
top: 1rem;
left: 1rem;
padding: 0.8rem 1rem;
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 0.82rem;
line-height: 1.7;
color: #9aa5b1;
}
.hud .label {
color: #6b7785;
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.65rem;
margin-right: 0.5rem;
}
.hud .value { color: #4ef0a8; }
.legend {
bottom: 1rem; left: 1rem;
padding: 0.8rem 1rem;
font-size: 0.82rem;
color: #9aa5b1;
}
.legend kbd {
font-family: ui-monospace, monospace;
font-size: 0.78em;
color: #e6edf3;
background: #11161d;
border: 1px solid #2a3441;
border-bottom-width: 2px;
border-radius: 3px;
padding: 0.05em 0.4em;
}
</style>
</head>
<body>
<div class="panel hud">
<div><span class="label">fps</span><span class="value" id="fps">--</span></div>
<div><span class="label">effects</span><span class="value" id="count">5</span></div>
</div>
<div class="panel legend">
drag to orbit · <kbd>WASD</kbd> to pan · scroll to zoom
</div>
<script type="module" src="./src/main.js"></script>
</body>
</html>
{
"name": "@meep-examples/particles",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Five ParticleEffect effects with readable INIT/UPDATE simulation graph sources.",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@woosh/meep-engine": "3.41.0"
},
"devDependencies": {
"@rollup/plugin-strip": "^3.0.4",
"vite": "^8.0.13"
}
}
# Particle effects
Five orbiting effects built with `ParticleEffect` and `GPUParticleEmitterSystem`:
buff, magic attack, healing, earth and chimney smoke. Their 13 textured layers
run INIT/UPDATE simulation programs on the GPU.
## Run
```sh
npm install
npm run dev
```
Open `/demo.html` in a supported WebGPU browser. `npm run build` writes the demo
to `../../public/examples/particles/` in this repository.
## Read the source
- `src/main.js` registers the systems, builds the scene and creates one
`ParticleEffect` entity per layer. Layers in an effect share an orbit.
- `src/ground.js` builds the grid backdrop separately from the particle example.
- `src/particleGraphs.js` builds the actual `NodeGraph` sources with
`particle_node` and `particle_wire`. INIT samples spawn position, velocity,
lifetime, base size and spin. UPDATE advances world-space position and age,
samples the size/colour curves, rotates the sprite and kills expired particles.
- `src/specs/*.json` holds the five effects' layer parameters. These are inputs
to the example's graph builder, not serialized engine components or bytecode.
Ranges are `[min, max]`. `size` is the billboard half-extent in metres; `radius`
is the spawn ellipsoid's radii (all zero for a point). `direction` is +Y or -Y
for these effects; `cone` is the spherical-cap angle in radians. `scale` stops
are `[normalizedAge, multiplier]`; `color` stops hold linear RGBA. Curves clamp
to their endpoints. The graph retains base size so scaling does not compound
from frame to frame.
`create_particle_effect` compiles the layout and graphs once at startup.
`ParticleEffect.from` binds their attributes to the renderer. The system loads
textures and shares atlas regions; particles use alpha blending, soft depth and
AVBOIT transparency. Existing particles remain in world space as emitters move.
Effects use the GPU simulation and `ParticleEffect` ECS component.
Meep 3.41.0 advances GPU particles with the engine's simulation clock. Emission,
motion and ageing follow its pause and speed modifiers at any display refresh
rate; the example uses the normal engine loop to supply that render delta.
## Assets
Textures resolve relative to `demo.html`, for example
`data/textures/particle/UETools/x64/Circle_02.png`. The repository includes these
sprites under `public/data/`; Vite copies that tree
into the built demo. Source downloads omit `public/`, so supply matching sprites
there or update the texture URLs before running a downloaded copy.
The engine bundles its image-decoder worker through the module asset URL; this
example does not need a separate worker copied beside `demo.html`.
See [Particles & VFX](https://meep.company-named.com/docs/rendering/particles/)
for a smaller complete graph and the Particular migration table.
import { defineConfig } from "vite";
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { resolve, dirname } from "node:path";
import strip from "@rollup/plugin-strip";
const __dirname = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
// Vite's dependency pre-bundling rewrites the engine's own asset imports and
// loses their URLs, so a dev server ends up serving ".../deps/undefined" for
// the renderer's data files. The engine is source-only ESM and needs no
// pre-bundling - but its CommonJS dependencies do, or the import of them from inside
// the excluded engine finds no default export.
optimizeDeps: {
exclude: ["@woosh/meep-engine"],
include: ["dat.gui", "opentype.js"],
},
plugins: [
{
// Copy the committed source thumbnail into the generated gallery folder.
// public/examples/<id>/ is build output (gitignored); thumbnail.png is
// kept in source here and copied through on every build so the gallery
// (src/data/examples.ts) can resolve it.
name: "copy-thumbnail",
apply: "build",
closeBundle() {
const thumb = resolve(__dirname, "thumbnail.png");
const dst = resolve(__dirname, "../../public/examples/particles");
if (existsSync(thumb)) {
mkdirSync(dst, { recursive: true });
copyFileSync(thumb, resolve(dst, "thumbnail.png"));
}
},
},
],
base: "./",
build: {
outDir: resolve(__dirname, "../../public/examples/particles"),
emptyOutDir: false,
rollupOptions: {
input: resolve(__dirname, "demo.html"),
plugins: [
{
// this will remove all assert statements from the production build
...strip(),
apply: 'build'
}
],
},
target: "es2022",
},
});
plus_1.png · 0.2 KB
Arc_01.png · 1.3 KB
Circle_02.png · 2.7 KB
Light_Beam_04.png · 3.0 KB
Smoke_08.png · 3.4 KB
Smoke_14.png · 3.1 KB
Stain_04.png · 3.6 KB
Star_12.png · 2.3 KB
Star_24.png · 1.5 KB
Star_42.png · 3.0 KB
thumbnail.png · 271.5 KB