cesiumjs-models-particles · git:20260827.49e0612 · 2026-08-27 · sha256 d7a34ac7ee209fcd

cesiumjs-models-particles git:20260827.49e0612A

Immutable. This exact content is served forever at /api/v1/blob/d7a34ac7ee209fcd.

---
name: cesiumjs-models-particles
description: "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."
---
# 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.

```js
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](REFERENCE.md) for the complete
support and authoring matrix. The same loader behavior applies to glTF content
inside 3D Tiles.

### Positioned Model with Heading

```js
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`.

```js
const model = await Model.fromGltfAsync({ url: "robot.glb" });
viewer.scene.primitives.add(model);

model.readyEvent.addEventListener(() => {
  console.log("Bounding sphere:", model.boundingSphere);
});
```

```js
// Synchronous check
if (model.ready) { const bs = model.boundingSphere; }
```

---

## Animations

Managed through `model.activeAnimations` (`ModelAnimationCollection`).

### Play by Name / Play All

```js
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

```js
animation.start.addEventListener((model, animation) => { });
animation.update.addEventListener((model, animation, time) => { });
animation.stop.addEventListener((model, animation) => { });
// Collection-level
model.activeAnimations.animationAdded.addEventListener((model, anim) => { });
```

```js
model.activeAnimations.remove(animation); // remove one
model.activeAnimations.removeAll();        // remove all
```

---

## Model Nodes

Override named node transforms for procedural animation (e.g., turret rotation).

```js
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

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

```js
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`:

```js
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

```js
// 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

```js
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.

```js
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

```js
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

```js
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.

```js
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:

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

```js
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.

```js
// 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:

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

```js
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