SuperAPI AI Directory

cesiumjs-models-particles

Writing & research

View repository

Updated 10-15 17:33

About this skill

CesiumJS Models, glTF & Particle Effects

Version baseline: CesiumJS v1.144.

Quick Reference

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.


Loading a glTF/GLB Model

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);

Public Sample Models

CesiumJS ships sample models usable without ion tokens:

https://raw.githubusercontent.com/CesiumGS/cesium/main/Apps/SampleData/models/CesiumAir/Cesium_Air.glb
https://raw.githubusercontent.com/CesiumGS/cesium/main/Apps/SampleData/models/CesiumMan/Cesium_Man.glb
https://raw.githubusercontent.com/CesiumGS/cesium/main/Apps/SampleData/models/CesiumMilkTruck/CesiumMilkTruck.glb

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.

Positioned Model with Heading

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);

Visual eval framing: for model screenshots, the model must be fully inside the frame and recognizable, not clipped at the bottom edge. Use minimumPixelSize 256-400 for public sample aircraft, add a subtle silhouetteColor/silhouetteSize, and place the camera with explicit coordinates aimed at the known model position. Avoid shallow pitches that put the model below the frame or rely on viewer.flyTo(model), which is version-sensitive for Model primitives.

Avoiding Distorted Model Appearance

Models can appear stretched or warped when scale is applied non-uniformly or when the orientation matrix is built incorrectly. Common pitfalls:

  • Always use Transforms.headingPitchRollToFixedFrame (or the Entity API's headingPitchRollQuaternion) for ground-aligned orientation. Hand-rolled quaternions often invert pitch/roll axes and produce vertically stretched silhouettes.
  • Use scale (uniform) for size, not a non-uniform Matrix4.fromScale. A non-uniform scale baked into modelMatrix will distort the model. Reserve Matrix4.fromScale for per-node tweaks (e.g., stretching a single turret), not the whole model.
  • Pair minimumPixelSize with a sensible maximumScale. Without a cap, distant small models can balloon to fill the frame and look elongated when the camera is close.

Key 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

Readiness and Lifecycle

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; }

Animations

Managed through model.activeAnimations (ModelAnimationCollection).

Play by Name / Play All

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).

Animations require viewer.clock.shouldAnimate = true to advance. A walking character will appear frozen mid-pose (or distorted if at the seam between keyframes) if the clock is stopped.

Animation Events

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

Model Nodes

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).


Coloring, Silhouettes, and Feature Picking

// 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;

Edge Display Mode (Experimental, 1.142+)

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);

Height Reference

// 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.


Particle Systems

ParticleSystem renders billboard-based effects. Position with modelMatrix (world) and emitterModelMatrix (local offset).

Always set viewer.clock.shouldAnimate = true before adding a particle system -- particles only move when the clock is running. A stopped clock produces a static "blob" at the emitter origin rather than a directional plume.

Producing a Legible Vertical Plume

A common failure mode is rendering a diffuse spherical blob instead of a recognizable rising column. To make plumes read as vertical:

  • Use CircleEmitter or a narrow ConeEmitter for upward-biased velocity. SphereEmitter and BoxEmitter radiate in all directions and produce blob-like shapes.
  • Bias velocity strongly upward -- emitters' local +Z is up in the modelMatrix frame; set modelMatrix via Transforms.eastNorthUpToFixedFrame so +Z is local up.
  • Use a multi-second minimumParticleLife / maximumParticleLife (e.g., 1.5-4.0) so particles travel far enough to form a visible column before fading.
  • Scale particles over their lifetime (startScale small, endScale 3-6x larger) so the plume widens with height, matching real smoke.
  • Keep the emitter footprint smaller than the visible plume. For crater smoke, use a small disk or point source; oversized ground ellipses read as the subject instead of a source marker.
  • Avoid solving clipping by making the plume huge. If the top is clipped, first increase camera range or lower particle life/speed/endScale; do not let the plume fill all four edges of the frame.
  • Wait for several seconds of simulation time before screenshotting -- the plume needs to develop. Advance the clock or use viewer.clock.shouldAnimate = true and wait.

Smoke Trail

import { ParticleSystem, CircleEmitter, Color, Cartesian2, Transforms, Cartesian3 } from "cesium";

viewer.clock.shouldAnimate = true; // required -- particles don't move on a stopped clock

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);

Volcanic / Crater Smoke Calibration

For terrain-scale smoke such as Mount St. Helens, use an oblique camera and a restrained source marker. The marker should confirm the emitter location without becoming a giant ground disk, and the plume should fit entirely in frame with terrain visible underneath.

import { ParticleSystem, CircleEmitter, Color, Cartesian2, Transforms, Cartesian3 } from "cesium";

viewer.clock.shouldAnimate = true;

const sourcePosition = Cartesian3.fromDegrees(-122.1944, 46.1914, 2549);

viewer.entities.add({
  position: sourcePosition,
  point: {
    pixelSize: 10,
    color: Color.ORANGE,
    outlineColor: Color.BLACK,
    outlineWidth: 2,
  },
});

const craterSmoke = new ParticleSystem({
  image: createRadialParticle(48, "rgba(180,180,180,0.75)"),
  startColor: Color.LIGHTGRAY.withAlpha(0.65),
  endColor: Color.WHITE.withAlpha(0.0),
  startScale: 0.8,
  endScale: 4.0,
  emissionRate: 35,
  minimumSpeed: 20.0,
  maximumSpeed: 45.0,
  minimumParticleLife: 2.0,
  maximumParticleLife: 4.0,
  imageSize: new Cartesian2(22, 22),
  emitter: new CircleEmitter(35.0),
  modelMatrix: Transforms.eastNorthUpToFixedFrame(sourcePosition),
  lifetime: 20.0,
  loop: true,
});
viewer.scene.primitives.add(craterSmoke);

viewer.trackedEntity = undefined;
viewer.camera.lookAt(
  sourcePosition,
  new Cesium.HeadingPitchRange(
    Cesium.Math.toRadians(35),
    Cesium.Math.toRadians(-22),
    6500
  )
);

Use this pattern when a prompt asks for smoke over a named geographic source:

  • Prefer a point or small billboard marker over an ellipse unless the prompt specifically asks for a ground footprint.
  • Keep the plume top, base, and source marker visible at once. A clipped plume or missing marker is a framing failure even if particles are rendering.
  • Use an oblique pitch for tall plumes. Near-nadir views flatten the vertical volume into a gray blob.

Emitter Types

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

Particle Bursts

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);

Update Callback (Gravity / Wind)

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);

Framing Particle Effects So the Map Is Visible

Particle effects should stand out against the map underneath, not against the sky or a featureless background. Two common framing failures:

  1. Camera too close + shallow pitch → the plume fills the frame and the map disappears behind it.
  2. Camera too steep / near-nadir → tall plumes flatten into a blob and the source geometry reads wrong.

Use viewer.camera.lookAt with HeadingPitchRange to anchor on the emitter and dial in an oblique view that keeps the map context visible:

const position = Cartesian3.fromDegrees(-122.1944, 46.1914, 2549);
viewer.trackedEntity = undefined;
viewer.camera.lookAt(
  position,
  new Cesium.HeadingPitchRange(
    Cesium.Math.toRadians(35),   // heading
    Cesium.Math.toRadians(-22),  // oblique pitch preserves plume height
    6500                         // increase range until top and base fit
  )
);

Guidelines:

  • Pitch in the -35° to -60° range for short, ground-hugging effects where map context matters more than vertical extent.
  • Pitch in the -10° to -30° range for tall plumes where vertical volume matters (e.g., volcanic columns).
  • Verify the marker/source is in-frame by including a billboard or point at the emitter location; if the camera is wrong, the marker will be missing from the screenshot and the issue is obvious.
  • Keep background terrain visible around the plume. If particles touch multiple image edges, reduce particle scale/life/speed or increase camera range before taking the screenshot.
  • Reset viewer.trackedEntity = undefined before lookAt, or the tracked entity's reference frame will override your camera transform.

Attaching Particles to a Moving Model

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());
});

Canvas-Based Particle Images

Generate particle textures dynamically instead of loading image files. A radial gradient produces soft, realistic edges for smoke and water effects.

// Soft radial-gradient particle (smoke, water, fog)
function createRadialParticle(size = 32, colorStop = "rgba(200,200,200,0.9)") {
  const c = document.createElement("canvas");
  c.width = c.height = size;
  const ctx = c.getContext("2d");
  const half = size / 2;
  const grad = ctx.createRadialGradient(half, half, 0, half, half, half);
  grad.addColorStop(0, colorStop);
  grad.addColorStop(1, "rgba(0,0,0,0)");
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, size, size);
  return c;
}

// Solid circle (high-contrast, fireworks, sparks)
function createCircleImage(size = 20) {
  const c = document.createElement("canvas");
  c.width = c.height = size;
  const ctx = c.getContext("2d");
  ctx.beginPath();
  ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
  ctx.fillStyle = "#fff";
  ctx.fill();
  return c;
}

// Pass canvas directly as image
new ParticleSystem({ image: createRadialParticle(), /* ...other options */ });

Use Color.fromCssColorString for specific particle colors when named colors don't suffice:

startColor: Cesium.Color.fromCssColorString("#66ccff").withAlpha(0.95),
endColor: Cesium.Color.WHITE.withAlpha(0.0),

Entity API Model (ModelGraphics)

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;

Framing trade-off with viewer.trackedEntity: tracking centers the model but uses an auto-computed range derived from the bounding sphere, which often produces a too-close, low-context shot. For prompts that ask for both the model and map context (terrain, landmarks, labels), prefer viewer.camera.flyTo / lookAt to a manually chosen position and clear viewer.trackedEntity = undefined first.


GPM Extension (NGA_gpm_local)

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.


Performance Tips

  1. Use .glb over .gltf -- binary format avoids extra HTTP requests and is smaller on the wire.
  2. Enable Draco compression (KHR_draco_mesh_compression) for 80-90% smaller meshes.
  3. Use KTX2/Basis textures (KHR_texture_basisu) for GPU-compressed textures; keep dimensions power-of-two.
  4. Set minimumPixelSize carefully -- large values force enlargement of distant models, increasing draw cost.
  5. Limit silhouettes -- extra rendering pass per silhouetted model; more than 256 may cause stencil artifacts.
  6. Reuse scratch Matrix4 objects -- avoid allocating every frame when syncing particle systems to moving entities.
  7. Match emission rate to effect density -- dense jets (fountains, fire) may need rates of 200-1000/s; diffuse smoke works well at 10-60/s. Profile on target hardware.
  8. Prefer pixel-sized particles (sizeInMeters: false, default) -- meter-sized particles are expensive at close range.
  9. Set finite 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.

See Also

  • cesiumjs-custom-shader -- GLSL authoring for Model.customShader (struct reference, feature IDs, metadata, vertex displacement)
  • cesiumjs-materials-shaders -- ImageBasedLighting, post-processing stages for models
  • cesiumjs-entities -- Entity API ModelGraphics, data sources, time-dynamic properties
  • cesiumjs-3d-tiles -- Cesium3DTileset (uses Model internally), clipping, styling