cesiumgs/cesiumjs-models-particles
CesiumJS models, glTF, and particle effects - Model, KHR_meshopt_compression, CAD glTF extensions, EdgeDisplayMode, ModelAnimation, ModelNode, ParticleSystem, emitters, GPM extensions. Use when loading compressed or CAD-style glTF/GLB models, controlling edge rendering, playing model animations, positioning particles, or working with geospatial positioning metadata.
npx skills add https://github.com/CesiumGS/cesiumjs-skills --skill cesiumjs-models-particles
Version baseline: CesiumJS v1.143.
| Class | Purpose |
|---|---|
| Model | Low-level glTF/GLB primitive; positioned via modelMatrix |
| ModelAnimation | Active animation instance on a model |
| ModelAnimationCollection | Collection at model.activeAnimations |
| ModelNode | Named node with modifiable transform |
| ModelFeature | Per-feature styling/picking for feature-ID models |
| EdgeDisplayMode | Controls draft glTF edge-visibility rendering on Model/Cesium3DTileset |
| ParticleSystem | Billboard-based particle manager (fire, smoke, rain) |
| Particle | Single particle with position, velocity, life |
| ParticleBurst | Scheduled burst of particles |
| BoxEmitter / CircleEmitter | Emit within box volume / flat disk |
| ConeEmitter / SphereEmitter | Emit from cone tip / within sphere |
The Entity API exposes models through ModelGraphics (see cesiumjs-entities). The Primitive API uses Model.fromGltfAsync for full control over modelMatrix, animations, and node transforms.
Always use the async factory -- never call the constructor directly.
import { Model, Cartesian3, Transforms, HeadingPitchRoll, Math as CesiumMath } from "cesium";
const model = await Model.fromGltfAsync({ url: "path/to/model.glb" });
viewer.scene.primitives.add(model);
CesiumJS 1.143 decodes KHR_meshopt_compression automatically, including the
v1 attribute codec and COLOR filter. Do not import a decoder or private loader
helper. When loading compressed glTF, CAD-style lines/points/edges, or
constant-LOD textures, read REFERENCE.md for the complete
support and authoring matrix. The same loader behavior applies to glTF content
inside 3D Tiles.
const position = Cartesian3.fromDegrees(-123.074, 44.050, 5000);
const hpr = new HeadingPitchRoll(CesiumMath.toRadians(135), 0, 0);
const model = await Model.fromGltfAsync({
url: "CesiumAir.glb",
modelMatrix: Transforms.headingPitchRollToFixedFrame(position, hpr),
minimumPixelSize: 128, // never smaller than 128 px on screen
maximumScale: 20000, // cap for minimumPixelSize enlargement
scale: 2.0, // uniform scale multiplier
});
viewer.scene.primitives.add(model);
Model.fromGltfAsync Options| Option | Type | Default |
|---|---|---|
| url | string\|Resource | required |
| modelMatrix | Matrix4 | IDENTITY |
| scale | number | 1.0 |
| minimumPixelSize | number | 0.0 |
| maximumScale | number | -- |
| show | boolean | true |
| color / colorBlendMode / colorBlendAmount | Color / ColorBlendMode / number | -- / HIGHLIGHT / 0.5 |
| edgeDisplayMode | EdgeDisplayMode | SURFACES_ONLY |
| silhouetteColor / silhouetteSize | Color / number | RED / 0.0 |
| shadows | ShadowMode | ENABLED |
| heightReference | HeightReference | NONE |
| customShader | CustomShader | -- |
| id | any | -- |
| allowPicking | boolean | true |
fromGltfAsync resolves once glTF JSON is parsed, but WebGL resources may still load. Wait for readyEvent before accessing animations, nodes, or boundingSphere.
const model = await Model.fromGltfAsync({ url: "robot.glb" });
viewer.scene.primitives.add(model);
model.readyEvent.addEventListener(() => {
console.log("Bounding sphere:", model.boundingSphere);
});
// Synchronous check
if (model.ready) { const bs = model.boundingSphere; }
Managed through model.activeAnimations (ModelAnimationCollection).
model.readyEvent.addEventListener(() => {
// Single animation
const anim = model.activeAnimations.add({
name: "Walk", // glTF animation name
loop: Cesium.ModelAnimationLoop.REPEAT, // NONE | REPEAT | MIRRORED_REPEAT
multiplier: 1.0, // playback speed (must be > 0)
});
anim.start.addEventListener((m, a) => console.log(`Started: ${a.name}`));
// Or play all animations at once
model.activeAnimations.addAll({
loop: Cesium.ModelAnimationLoop.REPEAT,
multiplier: 0.5,
});
});
Additional add options: index, reverse, startTime, stopTime, delay, removeOnStop, animationTime (custom time callback).
animation.start.addEventListener((model, animation) => { });
animation.update.addEventListener((model, animation, time) => { });
animation.stop.addEventListener((model, animation) => { });
// Collection-level
model.activeAnimations.animationAdded.addEventListener((model, anim) => { });
model.activeAnimations.remove(animation); // remove one
model.activeAnimations.removeAll(); // remove all
Override named node transforms for procedural animation (e.g., turret rotation).
model.readyEvent.addEventListener(() => {
const node = model.getNode("Turret");
node.matrix = Cesium.Matrix4.fromScale(
new Cesium.Cartesian3(5.0, 1.0, 1.0), node.matrix
);
});
Properties: name (read-only), id (read-only index), show (boolean), matrix (Matrix4 -- set to undefined to restore original and re-enable glTF animations).
// Tint + silhouette
model.color = Cesium.Color.RED.withAlpha(0.5);
model.colorBlendMode = Cesium.ColorBlendMode.MIX;
model.colorBlendAmount = 0.5;
model.silhouetteColor = Cesium.Color.YELLOW;
model.silhouetteSize = 2.0;
For glTF assets using the draft EXT_mesh_primitive_edge_visibility extension,
EdgeDisplayMode controls whether extension-provided edges are hidden, composited
over surfaces, or rendered alone. Models without the extension are unaffected.
import { EdgeDisplayMode, Model } from "cesium";
const model = await Model.fromGltfAsync({
url: "/models/cad-part.glb",
edgeDisplayMode: EdgeDisplayMode.SURFACES_AND_EDGES,
});
viewer.scene.primitives.add(model);
model.edgeDisplayMode = EdgeDisplayMode.EDGES_ONLY; // CAD-style wireframe
model.edgeDisplayMode = EdgeDisplayMode.SURFACES_ONLY; // default
When a glTF has EXT_mesh_features or EXT_structural_metadata, picking returns a ModelFeature:
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction((movement) => {
const picked = viewer.scene.pick(movement.endPosition);
if (picked instanceof Cesium.ModelFeature) {
picked.getPropertyIds().forEach((name) => {
console.log(`${name}: ${picked.getProperty(name)}`);
});
picked.color = Cesium.Color.YELLOW;
}
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
// Primitive API -- scene is required for height reference
const model = await Model.fromGltfAsync({
url: "truck.glb",
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
scene: viewer.scene,
});
// Entity API
viewer.entities.add({
position: Cartesian3.fromDegrees(-75.59, 40.03),
model: { uri: "truck.glb", heightReference: Cesium.HeightReference.CLAMP_TO_GROUND },
});
Values: NONE, CLAMP_TO_GROUND, RELATIVE_TO_GROUND, CLAMP_TO_TERRAIN, RELATIVE_TO_TERRAIN, CLAMP_TO_3D_TILE, RELATIVE_TO_3D_TILE.
ParticleSystem renders billboard-based effects. Position with modelMatrix (world) and emitterModelMatrix (local offset).
import { ParticleSystem, CircleEmitter, Color, Cartesian2, Transforms, Cartesian3 } from "cesium";
const smokeSystem = new ParticleSystem({
image: "smoke.png",
startColor: Color.LIGHTGRAY.withAlpha(0.7),
endColor: Color.WHITE.withAlpha(0.0),
startScale: 1.0,
endScale: 5.0,
emissionRate: 10,
minimumSpeed: 1.0,
maximumSpeed: 4.0,
minimumParticleLife: 1.2,
maximumParticleLife: 3.0,
imageSize: new Cartesian2(25, 25), // pixel size
emitter: new CircleEmitter(2.0), // radius in meters
modelMatrix: Transforms.eastNorthUpToFixedFrame(Cartesian3.fromDegrees(-75.157, 39.978)),
lifetime: 16.0,
loop: true,
});
viewer.scene.primitives.add(smokeSystem);
import { BoxEmitter, CircleEmitter, ConeEmitter, SphereEmitter } from "cesium";
new BoxEmitter(new Cesium.Cartesian3(10, 10, 10)); // 3D box, velocity outward
new CircleEmitter(2.0); // flat disk, velocity +Z
new ConeEmitter(Cesium.Math.toRadians(30)); // cone tip, velocity toward base
new SphereEmitter(5.0); // sphere, velocity radiates out
const firework = new ParticleSystem({
image: getParticleCanvas(),
startColor: Color.RED,
endColor: Color.RED.withAlpha(0.0),
particleLife: 1.0,
speed: 100.0,
imageSize: new Cartesian2(7, 7),
emissionRate: 0, // bursts only
emitter: new SphereEmitter(0.1),
bursts: [
new Cesium.ParticleBurst({ time: 0.0, minimum: 100, maximum: 200 }),
new Cesium.ParticleBurst({ time: 2.0, minimum: 50, maximum: 100 }),
new Cesium.ParticleBurst({ time: 4.0, minimum: 200, maximum: 300 }),
],
lifetime: 6.0,
loop: false,
modelMatrix: Transforms.eastNorthUpToFixedFrame(Cartesian3.fromDegrees(-75.597, 40.038)),
});
viewer.scene.primitives.add(firework);
The updateCallback runs per-particle per-frame for forces like gravity.
const gravityScratch = new Cesium.Cartesian3();
function applyGravity(particle, dt) {
Cesium.Cartesian3.normalize(particle.position, gravityScratch);
Cesium.Cartesian3.multiplyByScalar(gravityScratch, -9.8 * dt, gravityScratch);
particle.velocity = Cesium.Cartesian3.add(particle.velocity, gravityScratch, particle.velocity);
}
const system = new ParticleSystem({
image: "smoke.png",
emissionRate: 20,
emitter: new ConeEmitter(Cesium.Math.toRadians(45)),
updateCallback: applyGravity,
modelMatrix: Transforms.eastNorthUpToFixedFrame(Cartesian3.fromDegrees(-105, 40, 1000)),
});
viewer.scene.primitives.add(system);
Sync modelMatrix each frame via scene.preUpdate. Use emitterModelMatrix for a local offset (e.g., exhaust pipe).
const entity = viewer.entities.add({
position: sampledPosition,
orientation: new Cesium.VelocityOrientationProperty(sampledPosition),
model: { uri: "truck.glb", minimumPixelSize: 64 },
});
// Local offset to exhaust pipe
const trs = new Cesium.TranslationRotationScale();
trs.translation = new Cesium.Cartesian3(-4.0, 0.0, 1.4);
const emitterModelMatrix = Cesium.Matrix4.fromTranslationRotationScale(trs, new Cesium.Matrix4());
const exhaust = new ParticleSystem({
image: "smoke.png",
startColor: Color.GRAY.withAlpha(0.7),
endColor: Color.TRANSPARENT,
emissionRate: 8,
speed: 2.0,
particleLife: 1.5,
imageSize: new Cartesian2(20, 20),
emitter: new CircleEmitter(0.5),
emitterModelMatrix: emitterModelMatrix,
});
viewer.scene.primitives.add(exhaust);
viewer.scene.preUpdate.addEventListener((scene, time) => {
exhaust.modelMatrix = entity.computeModelMatrix(time, new Cesium.Matrix4());
});
Generate particle textures dynamically instead of loading image files.
function createCircleImage() {
const c = document.createElement("canvas");
c.width = c.height = 20;
const ctx = c.getContext("2d");
ctx.beginPath();
ctx.arc(10, 10, 10, 0, Math.PI * 2);
ctx.fillStyle = "#fff";
ctx.fill();
return c;
}
// Pass canvas directly as image
new ParticleSystem({ image: createCircleImage(), /* ...other options */ });
For simpler use cases, add a model through the Entity API (see cesiumjs-entities for full coverage).
const entity = viewer.entities.add({
name: "Aircraft",
position: Cartesian3.fromDegrees(-123.074, 44.050, 5000),
orientation: Cesium.Transforms.headingPitchRollQuaternion(
Cartesian3.fromDegrees(-123.074, 44.050, 5000),
new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(135), 0, 0)
),
model: {
uri: "CesiumAir.glb",
minimumPixelSize: 128,
maximumScale: 20000,
silhouetteColor: Color.RED,
silhouetteSize: 2.0,
},
});
viewer.trackedEntity = entity;
CesiumJS experimentally supports the NGA Geospatial Positioning Metadata glTF extension. Types: AnchorPointDirect, AnchorPointIndirect, CorrelationGroup, GltfGpmLocal, Spdcf. Parsed automatically when loading a glTF with NGA_gpm_local -- the API is experimental and subject to change.
.glb over .gltf -- binary format avoids extra HTTP requests and is smaller on the wire.KHR_draco_mesh_compression) for 80-90% smaller meshes.KHR_texture_basisu) for GPU-compressed textures; keep dimensions power-of-two.minimumPixelSize carefully -- large values force enlargement of distant models, increasing draw cost.Matrix4 objects -- avoid allocating every frame when syncing particle systems to moving entities.sizeInMeters: false, default) -- meter-sized particles are expensive at close range.lifetime on particle systems -- Number.MAX_VALUE (default) prevents pool cleanup.10. Disable picking for decorations -- allowPicking: false saves GPU memory on models that need no interaction.
11. Destroy when done -- viewer.scene.primitives.remove(model) then model.destroy() to free WebGL resources.
Model.customShader (struct reference, feature IDs, metadata, vertex displacement)Take cesiumgs/cesiumjs-models-particles from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.