From 46ec4203a256c0e50d942f685a4ba54059d42433 Mon Sep 17 00:00:00 2001
From: Joshua Barretto <joshua.s.barretto@gmail.com>
Date: Thu, 4 Nov 2021 12:45:08 +0000
Subject: [PATCH] Arbitrary volume airships

---
 common/Cargo.toml                    |   2 +-
 common/src/cmd.rs                    |   3 +
 common/src/comp/body.rs              |   1 +
 common/src/comp/body/ship.rs         |  72 +++++++++--
 common/src/comp/phys.rs              |  16 ++-
 common/src/volumes/dyna.rs           |  22 ++++
 common/systems/src/phys.rs           |  56 +++++---
 server/src/cmd.rs                    |  50 ++++++-
 server/src/events/entity_creation.rs |   4 +-
 server/src/state_ext.rs              |  14 +-
 voxygen/anim/src/lib.rs              |   2 +-
 voxygen/anim/src/ship/mod.rs         |   4 +
 voxygen/src/scene/figure/cache.rs    |  11 +-
 voxygen/src/scene/figure/load.rs     |  30 ++++-
 voxygen/src/scene/figure/mod.rs      | 187 ++++++++++++++++++++++-----
 voxygen/src/scene/figure/volume.rs   | 100 ++++++++++++++
 voxygen/src/scene/mod.rs             |   4 +-
 voxygen/src/scene/simple.rs          |   1 +
 18 files changed, 477 insertions(+), 102 deletions(-)
 create mode 100644 voxygen/src/scene/figure/volume.rs

diff --git a/common/Cargo.toml b/common/Cargo.toml
index 126081e07d..0e197365de 100644
--- a/common/Cargo.toml
+++ b/common/Cargo.toml
@@ -64,7 +64,7 @@ csv = { version = "1.1.3", optional = true }
 structopt = { version = "0.3.13", optional = true }
 # graphviz exporters
 petgraph = { version = "0.5.1", optional = true }
-# K-d trees used for RRT pathfinding 
+# K-d trees used for RRT pathfinding
 kiddo = { version = "0.1", optional = true }
 
 # Data structures
diff --git a/common/src/cmd.rs b/common/src/cmd.rs
index 69398bf91f..02c78b683c 100644
--- a/common/src/cmd.rs
+++ b/common/src/cmd.rs
@@ -107,6 +107,7 @@ pub enum ChatCommand {
     Whitelist,
     Wiring,
     World,
+    MakeVolume,
 }
 
 #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]
@@ -638,6 +639,7 @@ impl ChatCommand {
                 "Send messages to everyone on the server",
                 None,
             ),
+            ChatCommand::MakeVolume => cmd(vec![], "Create a volume (experimental)", Some(Admin)),
         }
     }
 
@@ -709,6 +711,7 @@ impl ChatCommand {
             ChatCommand::Wiring => "wiring",
             ChatCommand::Whitelist => "whitelist",
             ChatCommand::World => "world",
+            ChatCommand::MakeVolume => "make_volume",
         }
     }
 
diff --git a/common/src/comp/body.rs b/common/src/comp/body.rs
index 2d2db26548..d624c1d75c 100644
--- a/common/src/comp/body.rs
+++ b/common/src/comp/body.rs
@@ -859,6 +859,7 @@ impl Body {
                 ship::Body::AirBalloon => [0.0, 0.0, 5.0],
                 ship::Body::SailBoat => [-2.0, -5.0, 4.0],
                 ship::Body::Galleon => [-2.0, -5.0, 4.0],
+                ship::Body::Volume => [0.0, 0.0, 0.0],
             },
             _ => [0.0, 0.0, 0.0],
         }
diff --git a/common/src/comp/body/ship.rs b/common/src/comp/body/ship.rs
index 79121e4aff..bc9286e807 100644
--- a/common/src/comp/body/ship.rs
+++ b/common/src/comp/body/ship.rs
@@ -1,11 +1,14 @@
 use crate::{
-    comp::{Density, Mass},
+    comp::{Collider, Density, Mass},
     consts::{AIR_DENSITY, WATER_DENSITY},
     make_case_elim,
+    terrain::{Block, BlockKind, SpriteKind},
+    volumes::dyna::Dyna,
 };
 use rand::prelude::SliceRandom;
 use serde::{Deserialize, Serialize};
-use vek::Vec3;
+use std::sync::Arc;
+use vek::*;
 
 pub const ALL_BODIES: [Body; 4] = [
     Body::DefaultAirship,
@@ -23,6 +26,7 @@ make_case_elim!(
         AirBalloon = 1,
         SailBoat = 2,
         Galleon = 3,
+        Volume = 4,
     }
 );
 
@@ -38,18 +42,21 @@ impl Body {
 
     pub fn random_with(rng: &mut impl rand::Rng) -> Self { *(&ALL_BODIES).choose(rng).unwrap() }
 
-    pub fn manifest_entry(&self) -> &'static str {
+    /// Return the structure manifest that this ship uses. `None` means that it
+    /// should be derived from the collider.
+    pub fn manifest_entry(&self) -> Option<&'static str> {
         match self {
-            Body::DefaultAirship => "airship_human.structure",
-            Body::AirBalloon => "air_balloon.structure",
-            Body::SailBoat => "sail_boat.structure",
-            Body::Galleon => "galleon.structure",
+            Body::DefaultAirship => Some("airship_human.structure"),
+            Body::AirBalloon => Some("air_balloon.structure"),
+            Body::SailBoat => Some("sail_boat.structure"),
+            Body::Galleon => Some("galleon.structure"),
+            Body::Volume => None,
         }
     }
 
     pub fn dimensions(&self) -> Vec3<f32> {
         match self {
-            Body::DefaultAirship => Vec3::new(25.0, 50.0, 40.0),
+            Body::DefaultAirship | Body::Volume => Vec3::new(25.0, 50.0, 40.0),
             Body::AirBalloon => Vec3::new(25.0, 50.0, 40.0),
             Body::SailBoat => Vec3::new(13.0, 31.0, 3.0),
             Body::Galleon => Vec3::new(13.0, 32.0, 3.0),
@@ -58,7 +65,7 @@ impl Body {
 
     fn balloon_vol(&self) -> f32 {
         match self {
-            Body::DefaultAirship | Body::AirBalloon => {
+            Body::DefaultAirship | Body::AirBalloon | Body::Volume => {
                 let spheroid_vol = |equat_d: f32, polar_d: f32| -> f32 {
                     (std::f32::consts::PI / 6.0) * equat_d.powi(2) * polar_d
                 };
@@ -84,18 +91,39 @@ impl Body {
 
     pub fn density(&self) -> Density {
         match self {
-            Body::DefaultAirship | Body::AirBalloon => Density(AIR_DENSITY),
+            Body::DefaultAirship | Body::AirBalloon | Body::Volume => Density(AIR_DENSITY),
             _ => Density(AIR_DENSITY * 0.8 + WATER_DENSITY * 0.2), // Most boats should be buoyant
         }
     }
 
     pub fn mass(&self) -> Mass { Mass((self.hull_vol() + self.balloon_vol()) * self.density().0) }
 
-    pub fn can_fly(&self) -> bool { matches!(self, Body::DefaultAirship | Body::AirBalloon) }
+    pub fn can_fly(&self) -> bool {
+        matches!(self, Body::DefaultAirship | Body::AirBalloon | Body::Volume)
+    }
 
     pub fn has_water_thrust(&self) -> bool {
         !self.can_fly() // TODO: Differentiate this more carefully
     }
+
+    pub fn make_collider(&self) -> Collider {
+        match self.manifest_entry() {
+            Some(manifest_entry) => Collider::Voxel {
+                id: manifest_entry.to_string(),
+            },
+            None => {
+                use rand::prelude::*;
+                let sz = Vec3::broadcast(11);
+                Collider::Volume(Arc::new(figuredata::VoxelCollider::from_fn(sz, |_pos| {
+                    if thread_rng().gen_bool(0.25) {
+                        Block::new(BlockKind::Rock, Rgb::new(255, 0, 0))
+                    } else {
+                        Block::air(SpriteKind::Empty)
+                    }
+                })))
+            },
+        }
+    }
 }
 
 /// Terrain is 11.0 scale relative to small-scale voxels,
@@ -117,7 +145,7 @@ pub mod figuredata {
     };
     use hashbrown::HashMap;
     use lazy_static::lazy_static;
-    use serde::Deserialize;
+    use serde::{Deserialize, Serialize};
     use vek::Vec3;
 
     #[derive(Deserialize)]
@@ -148,10 +176,25 @@ pub mod figuredata {
         pub colliders: HashMap<String, VoxelCollider>,
     }
 
-    #[derive(Clone)]
+    #[derive(Clone, Debug, Serialize, Deserialize)]
     pub struct VoxelCollider {
-        pub dyna: Dyna<Block, (), ColumnAccess>,
+        pub(super) dyna: Dyna<Block, (), ColumnAccess>,
         pub translation: Vec3<f32>,
+        /// This value should be incremented every time the volume is mutated
+        /// and can be used to keep track of volume changes.
+        pub mut_count: usize,
+    }
+
+    impl VoxelCollider {
+        pub fn from_fn<F: FnMut(Vec3<i32>) -> Block>(sz: Vec3<u32>, f: F) -> Self {
+            Self {
+                dyna: Dyna::from_fn(sz, (), f),
+                translation: -sz.map(|e| e as f32) / 2.0,
+                mut_count: 0,
+            }
+        }
+
+        pub fn volume(&self) -> &Dyna<Block, (), ColumnAccess> { &self.dyna }
     }
 
     impl assets::Compound for ShipSpec {
@@ -180,6 +223,7 @@ pub mod figuredata {
                     let collider = VoxelCollider {
                         dyna,
                         translation: Vec3::from(bone.offset) + Vec3::from(bone.phys_offset),
+                        mut_count: 0,
                     };
                     colliders.insert(bone.central.0.clone(), collider);
                 }
diff --git a/common/src/comp/phys.rs b/common/src/comp/phys.rs
index c52dc49364..efa306fb28 100644
--- a/common/src/comp/phys.rs
+++ b/common/src/comp/phys.rs
@@ -1,9 +1,12 @@
 use super::{Fluid, Ori};
-use crate::{consts::WATER_DENSITY, terrain::Block, uid::Uid};
+use crate::{
+    comp::body::ship::figuredata::VoxelCollider, consts::WATER_DENSITY, terrain::Block, uid::Uid,
+};
 use hashbrown::HashSet;
 use serde::{Deserialize, Serialize};
 use specs::{Component, DerefFlaggedStorage, NullStorage};
 use specs_idvs::IdvStorage;
+use std::sync::Arc;
 use vek::*;
 
 /// Position
@@ -103,13 +106,16 @@ impl Component for Density {
 }
 
 // Collider
-#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[derive(Clone, Debug, Serialize, Deserialize)]
 pub enum Collider {
+    /// A volume based on an existing voxel asset.
     // TODO: pass the map from ids -> voxel data to get_radius
     // and get_z_limits to compute a bounding cylinder.
     Voxel {
         id: String,
     },
+    /// A mutable volume.
+    Volume(Arc<VoxelCollider>),
     /// Capsule prism with line segment from p0 to p1
     CapsulePrism {
         p0: Vec2<f32>,
@@ -122,9 +128,11 @@ pub enum Collider {
 }
 
 impl Collider {
+    pub fn is_voxel(&self) -> bool { matches!(self, Collider::Voxel { .. } | Collider::Volume(_)) }
+
     pub fn bounding_radius(&self) -> f32 {
         match self {
-            Collider::Voxel { .. } => 1.0,
+            Collider::Voxel { .. } | Collider::Volume(_) => 1.0,
             Collider::CapsulePrism { radius, p0, p1, .. } => {
                 let a = p0.distance(*p1);
                 a / 2.0 + *radius
@@ -140,7 +148,7 @@ impl Collider {
 
     pub fn get_z_limits(&self, modifier: f32) -> (f32, f32) {
         match self {
-            Collider::Voxel { .. } => (0.0, 1.0),
+            Collider::Voxel { .. } | Collider::Volume(_) => (0.0, 1.0),
             Collider::CapsulePrism { z_min, z_max, .. } => (*z_min * modifier, *z_max * modifier),
             Collider::Point => (0.0, 0.0),
         }
diff --git a/common/src/volumes/dyna.rs b/common/src/volumes/dyna.rs
index 2be6b25cbe..c51666a35c 100644
--- a/common/src/volumes/dyna.rs
+++ b/common/src/volumes/dyna.rs
@@ -129,6 +129,19 @@ impl<V: Clone, M, A: Access> Dyna<V, M, A> {
         }
     }
 
+    /// Same as [`Dyna::filled`], but with the voxel determined by the function
+    /// `f`.
+    pub fn from_fn<F: FnMut(Vec3<i32>) -> V>(sz: Vec3<u32>, meta: M, mut f: F) -> Self {
+        Self {
+            vox: (0..sz.product() as usize)
+                .map(|idx| f(A::pos(idx, sz)))
+                .collect(),
+            meta,
+            sz,
+            _phantom: std::marker::PhantomData,
+        }
+    }
+
     /// Get a reference to the internal metadata.
     pub fn metadata(&self) -> &M { &self.meta }
 
@@ -138,6 +151,8 @@ impl<V: Clone, M, A: Access> Dyna<V, M, A> {
 
 pub trait Access {
     fn idx(pos: Vec3<i32>, sz: Vec3<u32>) -> usize;
+    /// `idx` must be in range, permitted to panic otherwise.
+    fn pos(idx: usize, sz: Vec3<u32>) -> Vec3<i32>;
 }
 
 #[derive(Copy, Clone, Debug)]
@@ -147,4 +162,11 @@ impl Access for ColumnAccess {
     fn idx(pos: Vec3<i32>, sz: Vec3<u32>) -> usize {
         (pos.x * sz.y as i32 * sz.z as i32 + pos.y * sz.z as i32 + pos.z) as usize
     }
+
+    fn pos(idx: usize, sz: Vec3<u32>) -> Vec3<i32> {
+        let z = idx as u32 % sz.z;
+        let y = (idx as u32 / sz.z) % sz.y;
+        let x = idx as u32 / (sz.y * sz.z);
+        Vec3::new(x, y, z).map(|e| e as i32)
+    }
 }
diff --git a/common/systems/src/phys.rs b/common/systems/src/phys.rs
index d4a625870e..fd9b1f50d5 100644
--- a/common/systems/src/phys.rs
+++ b/common/systems/src/phys.rs
@@ -224,8 +224,15 @@ impl<'a> PhysicsData<'a> {
             phys_cache.scaled_radius = flat_radius;
 
             let neighborhood_radius = match collider {
+<<<<<<< HEAD
                 Collider::CapsulePrism { radius, .. } => radius * scale,
                 Collider::Voxel { .. } | Collider::Point => flat_radius,
+=======
+                Some(Collider::CapsulePrism { radius, .. }) => radius * scale,
+                Some(Collider::Voxel { .. } | Collider::Volume(_) | Collider::Point) | None => {
+                    flat_radius
+                },
+>>>>>>> 51f014dd3 (Arbitrary volume airships)
             };
             phys_cache.neighborhood_radius = neighborhood_radius;
 
@@ -265,7 +272,11 @@ impl<'a> PhysicsData<'a> {
                         Some((p0, p1))
                     }
                 },
+<<<<<<< HEAD
                 Collider::Voxel { .. } | Collider::Point => None,
+=======
+                Some(Collider::Voxel { .. } | Collider::Volume(_) | Collider::Point) | None => None,
+>>>>>>> 51f014dd3 (Arbitrary volume airships)
             };
             phys_cache.origins = origins;
             phys_cache.ori = ori;
@@ -527,13 +538,15 @@ impl<'a> PhysicsData<'a> {
         )
             .join()
         {
-            let voxel_id = match collider {
-                Collider::Voxel { id } => id,
-                _ => continue,
+            let voxel_colliders_manifest = VOXEL_COLLIDER_MANIFEST.read();
+            let vol = match collider {
+                Collider::Voxel { id } => voxel_colliders_manifest.colliders.get(&*id),
+                Collider::Volume(vol) => Some(&**vol),
+                _ => None,
             };
 
-            if let Some(voxel_collider) = VOXEL_COLLIDER_MANIFEST.read().colliders.get(&*voxel_id) {
-                let sphere = voxel_collider_bounding_sphere(voxel_collider, pos, ori);
+            if let Some(vol) = vol {
+                let sphere = voxel_collider_bounding_sphere(vol, pos, ori);
                 let radius = sphere.radius.ceil() as u32;
                 let pos_2d = sphere.center.xy().map(|e| e as i32);
                 const POS_TRUNCATION_ERROR: u32 = 1;
@@ -729,7 +742,7 @@ impl<'a> PhysicsData<'a> {
             !&read.mountings,
         )
             .par_join()
-            .filter(|tuple| matches!(tuple.3, Collider::Voxel { .. }) == terrain_like_entities)
+            .filter(|tuple| tuple.3.is_voxel() == terrain_like_entities)
             .map_init(
                 || {
                     prof_span!(guard, "physics e<>t rayon job");
@@ -760,7 +773,7 @@ impl<'a> PhysicsData<'a> {
                     let old_ori = *ori;
                     let mut ori = *ori;
 
-                    let scale = if let Collider::Voxel { .. } = collider {
+                    let scale = if collider.is_voxel() {
                         scale.map(|s| s.0).unwrap_or(1.0)
                     } else {
                         // TODO: Use scale & actual proportions when pathfinding is good
@@ -806,7 +819,7 @@ impl<'a> PhysicsData<'a> {
                         character_state.map_or(false, |cs| matches!(cs, CharacterState::Climb(_)));
 
                     match &collider {
-                        Collider::Voxel { .. } => {
+                        Collider::Voxel { .. } | Collider::Volume(_) => {
                             // For now, treat entities with voxel colliders
                             // as their bounding cylinders for the purposes of
                             // colliding them with terrain.
@@ -1029,10 +1042,13 @@ impl<'a> PhysicsData<'a> {
                                     return;
                                 }
 
-                                let voxel_id = if let Collider::Voxel { id } = collider_other {
-                                    id
-                                } else {
-                                    return;
+                                let voxel_colliders_manifest = VOXEL_COLLIDER_MANIFEST.read();
+                                let voxel_collider = match collider_other {
+                                    Collider::Voxel { id } => {
+                                        voxel_colliders_manifest.colliders.get(id)
+                                    },
+                                    Collider::Volume(vol) => Some(&**vol),
+                                    _ => None,
                                 };
 
                                 // use bounding cylinder regardless of our collider
@@ -1045,9 +1061,7 @@ impl<'a> PhysicsData<'a> {
                                 let z_min = 0.0;
                                 let z_max = z_max.clamped(1.2, 1.95) * scale;
 
-                                if let Some(voxel_collider) =
-                                    VOXEL_COLLIDER_MANIFEST.read().colliders.get(voxel_id)
-                                {
+                                if let Some(voxel_collider) = voxel_collider {
                                     // TODO: cache/precompute sphere?
                                     let voxel_sphere = voxel_collider_bounding_sphere(
                                         voxel_collider,
@@ -1112,7 +1126,7 @@ impl<'a> PhysicsData<'a> {
                                     let cylinder = (radius, z_min, z_max);
                                     box_voxel_collision(
                                         cylinder,
-                                        &voxel_collider.dyna,
+                                        &voxel_collider.volume(),
                                         entity,
                                         &mut cpos,
                                         transform_to.mul_point(tgt_pos - wpos),
@@ -1226,7 +1240,7 @@ impl<'a> PhysicsData<'a> {
             &read.colliders,
         )
             .join()
-            .filter(|tuple| matches!(tuple.5, Collider::Voxel { .. }) == terrain_like_entities)
+            .filter(|tuple| tuple.5.is_voxel() == terrain_like_entities)
         {
             if let Some(new_pos) = pos_vel_ori_defer.pos.take() {
                 *pos = new_pos;
@@ -1725,8 +1739,8 @@ fn voxel_collider_bounding_sphere(
 ) -> Sphere<f32, f32> {
     let origin_offset = voxel_collider.translation;
     use common::vol::SizedVol;
-    let lower_bound = voxel_collider.dyna.lower_bound().map(|e| e as f32);
-    let upper_bound = voxel_collider.dyna.upper_bound().map(|e| e as f32);
+    let lower_bound = voxel_collider.volume().lower_bound().map(|e| e as f32);
+    let upper_bound = voxel_collider.volume().upper_bound().map(|e| e as f32);
     let center = (lower_bound + upper_bound) / 2.0;
     // Compute vector from the origin (where pos value corresponds to) and the model
     // center
@@ -1845,8 +1859,8 @@ fn resolve_e2e_collision(
         && (!is_sticky || is_mid_air)
         && diff.magnitude_squared() > 0.0
         && !is_projectile
-        && !matches!(collider_other, Collider::Voxel { .. })
-        && !matches!(collider, Collider::Voxel { .. })
+        && !collider_other.map_or(false, |c| c.is_voxel())
+        && !collider.map_or(false, |c| c.is_voxel())
     {
         const ELASTIC_FORCE_COEFFICIENT: f32 = 400.0;
         let mass_coefficient = mass_other.0 / (mass.0 + mass_other.0);
diff --git a/server/src/cmd.rs b/server/src/cmd.rs
index ba1d9c3c37..2a01345d47 100644
--- a/server/src/cmd.rs
+++ b/server/src/cmd.rs
@@ -35,9 +35,9 @@ use common::{
     generation::EntityInfo,
     npc::{self, get_npc_name},
     resources::{BattleMode, PlayerPhysicsSettings, Time, TimeOfDay},
-    terrain::{Block, BlockKind, SpriteKind, TerrainChunkSize},
+    terrain::{Block, BlockKind, SpriteKind, TerrainChunkSize, TerrainGrid},
     uid::Uid,
-    vol::RectVolSize,
+    vol::{ReadVol, RectVolSize},
     Damage, DamageKind, DamageSource, Explosion, LoadoutBuilder, RadiusEffect,
 };
 use common_net::{
@@ -50,7 +50,7 @@ use hashbrown::{HashMap, HashSet};
 use humantime::Duration as HumanDuration;
 use rand::Rng;
 use specs::{storage::StorageEntry, Builder, Entity as EcsEntity, Join, WorldExt};
-use std::str::FromStr;
+use std::{str::FromStr, sync::Arc};
 use vek::*;
 use wiring::{Circuit, Wire, WiringAction, WiringActionEffect, WiringElement};
 use world::util::Sampler;
@@ -176,6 +176,7 @@ fn do_command(
         ChatCommand::Wiring => handle_spawn_wiring,
         ChatCommand::Whitelist => handle_whitelist,
         ChatCommand::World => handle_world,
+        ChatCommand::MakeVolume => handle_make_volume,
     };
 
     handler(server, client, target, args, cmd)
@@ -1269,7 +1270,7 @@ fn handle_spawn_airship(
     let ship = comp::ship::Body::random();
     let mut builder = server
         .state
-        .create_ship(pos, ship, true)
+        .create_ship(pos, ship, |ship| ship.make_collider(), true)
         .with(LightEmitter {
             col: Rgb::new(1.0, 0.65, 0.2),
             strength: 2.0,
@@ -1293,6 +1294,47 @@ fn handle_spawn_airship(
     Ok(())
 }
 
+fn handle_make_volume(
+    server: &mut Server,
+    client: EcsEntity,
+    target: EcsEntity,
+    _args: Vec<String>,
+    _action: &ChatCommand,
+) -> CmdResult<()> {
+    use comp::body::ship::figuredata::VoxelCollider;
+    use rand::prelude::*;
+
+    //let () = parse_args!(args);
+    let mut pos = position(server, target, "target")?;
+    let ship = comp::ship::Body::Volume;
+    let sz = Vec3::new(15, 15, 15);
+    let collider = {
+        let terrain = server.state().terrain();
+        comp::Collider::Volume(Arc::new(VoxelCollider::from_fn(sz, |rpos| {
+            terrain
+                .get(pos.0.map(|e| e.floor() as i32) + rpos - sz.map(|e| e as i32) / 2)
+                .ok()
+                .copied()
+                .unwrap_or_else(Block::empty)
+        })))
+    };
+    server
+        .state
+        .create_ship(
+            comp::Pos(pos.0 + Vec3::unit_z() * 50.0),
+            ship,
+            move |_| collider,
+            true,
+        )
+        .build();
+
+    server.notify_client(
+        client,
+        ServerGeneral::server_msg(ChatType::CommandInfo, "Created a volume"),
+    );
+    Ok(())
+}
+
 fn handle_spawn_campfire(
     server: &mut Server,
     client: EcsEntity,
diff --git a/server/src/events/entity_creation.rs b/server/src/events/entity_creation.rs
index 4d6cbc4cd2..06d4e4fc4d 100644
--- a/server/src/events/entity_creation.rs
+++ b/server/src/events/entity_creation.rs
@@ -156,7 +156,9 @@ pub fn handle_create_ship(
     agent: Option<Agent>,
     rtsim_entity: Option<RtSimEntity>,
 ) {
-    let mut entity = server.state.create_ship(pos, ship, mountable);
+    let mut entity = server
+        .state
+        .create_ship(pos, ship, |ship| ship.make_collider(), mountable);
     if let Some(mut agent) = agent {
         let (kp, ki, kd) = pid_coefficients(&Body::Ship(ship));
         fn pure_z(sp: Vec3<f32>, pv: Vec3<f32>) -> f32 { (sp - pv).z }
diff --git a/server/src/state_ext.rs b/server/src/state_ext.rs
index 1b4dc8dd5f..9ae8599926 100644
--- a/server/src/state_ext.rs
+++ b/server/src/state_ext.rs
@@ -51,10 +51,11 @@ pub trait StateExt {
     ) -> EcsEntityBuilder;
     /// Build a static object entity
     fn create_object(&mut self, pos: comp::Pos, object: comp::object::Body) -> EcsEntityBuilder;
-    fn create_ship(
+    fn create_ship<F: FnOnce(comp::ship::Body) -> comp::Collider>(
         &mut self,
         pos: comp::Pos,
         ship: comp::ship::Body,
+        make_collider: F,
         mountable: bool,
     ) -> EcsEntityBuilder;
     /// Build a projectile
@@ -200,9 +201,7 @@ impl StateExt for State {
             .with(body.mass())
             .with(body.density())
             .with(match body {
-                comp::Body::Ship(ship) => comp::Collider::Voxel {
-                    id: ship.manifest_entry().to_string(),
-                },
+                comp::Body::Ship(ship) => ship.make_collider(),
                 _ => capsule(&body),
             })
             .with(comp::Controller::default())
@@ -243,10 +242,11 @@ impl StateExt for State {
             .with(body)
     }
 
-    fn create_ship(
+    fn create_ship<F: FnOnce(comp::ship::Body) -> comp::Collider>(
         &mut self,
         pos: comp::Pos,
         ship: comp::ship::Body,
+        make_collider: F,
         mountable: bool,
     ) -> EcsEntityBuilder {
         let body = comp::Body::Ship(ship);
@@ -258,9 +258,7 @@ impl StateExt for State {
             .with(comp::Ori::default())
             .with(body.mass())
             .with(body.density())
-            .with(comp::Collider::Voxel {
-                id: ship.manifest_entry().to_string(),
-            })
+            .with(make_collider(ship))
             .with(body)
             .with(comp::Scale(comp::ship::AIRSHIP_SCALE))
             .with(comp::Controller::default())
diff --git a/voxygen/anim/src/lib.rs b/voxygen/anim/src/lib.rs
index c894de6835..edc81e626e 100644
--- a/voxygen/anim/src/lib.rs
+++ b/voxygen/anim/src/lib.rs
@@ -81,7 +81,7 @@ pub struct FigureBoneData(pub MatRaw, pub MatRaw);
 
 pub const MAX_BONE_COUNT: usize = 16;
 
-fn make_bone(mat: Mat4<f32>) -> FigureBoneData {
+pub fn make_bone(mat: Mat4<f32>) -> FigureBoneData {
     let normal = mat.map_cols(Vec4::normalized);
     FigureBoneData(mat.into_col_arrays(), normal.into_col_arrays())
 }
diff --git a/voxygen/anim/src/ship/mod.rs b/voxygen/anim/src/ship/mod.rs
index 5a32b68326..81d09e9a90 100644
--- a/voxygen/anim/src/ship/mod.rs
+++ b/voxygen/anim/src/ship/mod.rs
@@ -95,24 +95,28 @@ impl<'a> From<&'a Body> for SkeletonAttr {
                 AirBalloon => (0.0, 0.0, 0.0),
                 SailBoat => (0.0, 0.0, 0.0),
                 Galleon => (0.0, 0.0, 0.0),
+                Volume => (0.0, 0.0, 0.0),
             },
             bone1: match body {
                 DefaultAirship => (-13.0, -25.0, 10.0),
                 AirBalloon => (0.0, 0.0, 0.0),
                 SailBoat => (0.0, 0.0, 0.0),
                 Galleon => (0.0, 0.0, 0.0),
+                Volume => (0.0, 0.0, 0.0),
             },
             bone2: match body {
                 DefaultAirship => (13.0, -25.0, 10.0),
                 AirBalloon => (0.0, 0.0, 0.0),
                 SailBoat => (0.0, 0.0, 0.0),
                 Galleon => (0.0, 0.0, 0.0),
+                Volume => (0.0, 0.0, 0.0),
             },
             bone3: match body {
                 DefaultAirship => (0.0, -27.5, 8.5),
                 AirBalloon => (0.0, -9.0, 8.0),
                 SailBoat => (0.0, 0.0, 0.0),
                 Galleon => (0.0, 0.0, 0.0),
+                Volume => (0.0, 0.0, 0.0),
             },
         }
     }
diff --git a/voxygen/src/scene/figure/cache.rs b/voxygen/src/scene/figure/cache.rs
index 7f25fd606c..ca35f63874 100644
--- a/voxygen/src/scene/figure/cache.rs
+++ b/voxygen/src/scene/figure/cache.rs
@@ -6,7 +6,6 @@ use crate::{
 };
 use anim::Skeleton;
 use common::{
-    assets::AssetHandle,
     comp::{
         inventory::{
             slot::{ArmorSlot, EquipSlot},
@@ -287,7 +286,7 @@ where
     Skel::Body: BodySpec,
 {
     models: HashMap<FigureKey<Skel::Body>, ((FigureModelEntryFuture<LOD_COUNT>, Skel::Attr), u64)>,
-    manifests: AssetHandle<<Skel::Body as BodySpec>::Spec>,
+    manifests: <Skel::Body as BodySpec>::Manifests,
 }
 
 impl<Skel: Skeleton> FigureModelCache<Skel>
@@ -345,6 +344,7 @@ where
         col_lights: &mut super::FigureColLights,
         body: Skel::Body,
         inventory: Option<&Inventory>,
+        extra: <Skel::Body as BodySpec>::Extra,
         tick: u64,
         camera_mode: CameraMode,
         character_state: Option<&CharacterState>,
@@ -407,13 +407,12 @@ where
             Entry::Vacant(v) => {
                 let key = v.key().clone();
                 let slot = Arc::new(atomic::AtomicCell::new(None));
-                let manifests = self.manifests;
+                let manifests = self.manifests.clone();
                 let slot_ = Arc::clone(&slot);
 
                 slow_jobs.spawn("FIGURE_MESHING", move || {
                     // First, load all the base vertex data.
-                    let manifests = &*manifests.read();
-                    let meshes = <Skel::Body as BodySpec>::bone_meshes(&key, manifests);
+                    let meshes = <Skel::Body as BodySpec>::bone_meshes(&key, &manifests, extra);
 
                     // Then, set up meshing context.
                     let mut greedy = FigureModel::make_greedy();
@@ -562,7 +561,7 @@ where
     {
         // Check for reloaded manifests
         // TODO: maybe do this in a different function, maintain?
-        if self.manifests.reloaded() {
+        if <Skel::Body as BodySpec>::is_reloaded(&mut self.manifests) {
             col_lights.atlas.clear();
             self.models.clear();
         }
diff --git a/voxygen/src/scene/figure/load.rs b/voxygen/src/scene/figure/load.rs
index a249148052..6f8372c6a5 100644
--- a/voxygen/src/scene/figure/load.rs
+++ b/voxygen/src/scene/figure/load.rs
@@ -87,9 +87,14 @@ fn recolor_grey(rgb: Rgb<u8>, color: Rgb<u8>) -> Rgb<u8> {
 /// A set of reloadable specifications for a Body.
 pub trait BodySpec: Sized {
     type Spec;
+    type Manifests: Send + Sync + Clone;
+    type Extra: Send + Sync;
 
     /// Initialize all the specifications for this Body.
-    fn load_spec() -> Result<AssetHandle<Self::Spec>, assets::Error>;
+    fn load_spec() -> Result<Self::Manifests, assets::Error>;
+
+    /// Determine whether the cache's manifest was reloaded
+    fn is_reloaded(manifests: &mut Self::Manifests) -> bool;
 
     /// Mesh bones using the given spec, character state, and mesh generation
     /// function.
@@ -100,7 +105,8 @@ pub trait BodySpec: Sized {
     /// in which case this strategy might change.
     fn bone_meshes(
         key: &FigureKey<Self>,
-        spec: &Self::Spec,
+        manifests: &Self::Manifests,
+        extra: Self::Extra,
     ) -> [Option<BoneMeshes>; anim::MAX_BONE_COUNT];
 }
 
@@ -125,16 +131,22 @@ macro_rules! make_vox_spec {
 
         impl BodySpec for $body {
             type Spec = $Spec;
+            type Manifests = AssetHandle<Self::Spec>;
+            type Extra = ();
 
             #[allow(unused_variables)]
-            fn load_spec() -> Result<AssetHandle<Self::Spec>, assets::Error> {
+            fn load_spec() -> Result<Self::Manifests, assets::Error> {
                 Self::Spec::load("")
             }
 
+            fn is_reloaded(manifests: &mut Self::Manifests) -> bool { manifests.reloaded() }
+
             fn bone_meshes(
                 $self_pat: &FigureKey<Self>,
-                $spec_pat: &Self::Spec,
+                manifests: &Self::Manifests,
+                _: Self::Extra,
             ) -> [Option<BoneMeshes>; anim::MAX_BONE_COUNT] {
+                let $spec_pat = &*manifests.read();
                 $bone_meshes
             }
         }
@@ -4528,15 +4540,21 @@ fn mesh_ship_bone<K: fmt::Debug + Eq + Hash, V, F: Fn(&V) -> &ShipCentralSubSpec
 }
 
 impl BodySpec for ship::Body {
+    type Extra = ();
+    type Manifests = AssetHandle<Self::Spec>;
     type Spec = ShipSpec;
 
     #[allow(unused_variables)]
-    fn load_spec() -> Result<AssetHandle<Self::Spec>, assets::Error> { Self::Spec::load("") }
+    fn load_spec() -> Result<Self::Manifests, assets::Error> { Self::Spec::load("") }
+
+    fn is_reloaded(manifests: &mut Self::Manifests) -> bool { manifests.reloaded() }
 
     fn bone_meshes(
         FigureKey { body, .. }: &FigureKey<Self>,
-        spec: &Self::Spec,
+        manifests: &Self::Manifests,
+        _: Self::Extra,
     ) -> [Option<BoneMeshes>; anim::MAX_BONE_COUNT] {
+        let spec = &*manifests.read();
         let map = &(spec.central.read().0).0;
         [
             Some(mesh_ship_bone(map, body, |spec| &spec.bone0)),
diff --git a/voxygen/src/scene/figure/mod.rs b/voxygen/src/scene/figure/mod.rs
index b50098b474..89e44e4747 100644
--- a/voxygen/src/scene/figure/mod.rs
+++ b/voxygen/src/scene/figure/mod.rs
@@ -1,8 +1,10 @@
 mod cache;
 pub mod load;
+mod volume;
 
 pub use cache::FigureModelCache;
 pub use load::load_mesh; // TODO: Don't make this public.
+pub use volume::VolumeKey;
 
 use crate::{
     ecs::comp::Interpolated,
@@ -30,7 +32,7 @@ use common::{
     comp::{
         inventory::slot::EquipSlot,
         item::{Hands, ItemKind, ToolKind},
-        Body, CharacterState, Controller, Health, Inventory, Item, Last, LightAnimation,
+        Body, CharacterState, Collider, Controller, Health, Inventory, Item, Last, LightAnimation,
         LightEmitter, Mounting, Ori, PhysicsState, PoiseState, Pos, Scale, Vel,
     },
     resources::DeltaTime,
@@ -113,6 +115,7 @@ struct FigureMgrStates {
     golem_states: HashMap<EcsEntity, FigureState<GolemSkeleton>>,
     object_states: HashMap<EcsEntity, FigureState<ObjectSkeleton>>,
     ship_states: HashMap<EcsEntity, FigureState<ShipSkeleton>>,
+    volume_states: HashMap<EcsEntity, FigureState<VolumeKey>>,
 }
 
 impl FigureMgrStates {
@@ -133,6 +136,7 @@ impl FigureMgrStates {
             golem_states: HashMap::new(),
             object_states: HashMap::new(),
             ship_states: HashMap::new(),
+            volume_states: HashMap::new(),
         }
     }
 
@@ -193,7 +197,13 @@ impl FigureMgrStates {
                 .map(DerefMut::deref_mut),
             Body::Golem(_) => self.golem_states.get_mut(entity).map(DerefMut::deref_mut),
             Body::Object(_) => self.object_states.get_mut(entity).map(DerefMut::deref_mut),
-            Body::Ship(_) => self.ship_states.get_mut(entity).map(DerefMut::deref_mut),
+            Body::Ship(ship) => {
+                if ship.manifest_entry().is_some() {
+                    self.ship_states.get_mut(entity).map(DerefMut::deref_mut)
+                } else {
+                    self.volume_states.get_mut(entity).map(DerefMut::deref_mut)
+                }
+            },
         }
     }
 
@@ -217,7 +227,13 @@ impl FigureMgrStates {
             Body::BipedSmall(_) => self.biped_small_states.remove(entity).map(|e| e.meta),
             Body::Golem(_) => self.golem_states.remove(entity).map(|e| e.meta),
             Body::Object(_) => self.object_states.remove(entity).map(|e| e.meta),
-            Body::Ship(_) => self.ship_states.remove(entity).map(|e| e.meta),
+            Body::Ship(ship) => {
+                if ship.manifest_entry().is_some() {
+                    self.ship_states.remove(entity).map(|e| e.meta)
+                } else {
+                    self.volume_states.remove(entity).map(|e| e.meta)
+                }
+            },
         }
     }
 
@@ -238,6 +254,7 @@ impl FigureMgrStates {
         self.golem_states.retain(|k, v| f(k, &mut *v));
         self.object_states.retain(|k, v| f(k, &mut *v));
         self.ship_states.retain(|k, v| f(k, &mut *v));
+        self.volume_states.retain(|k, v| f(k, &mut *v));
     }
 
     fn count(&self) -> usize {
@@ -257,6 +274,7 @@ impl FigureMgrStates {
             + self.golem_states.len()
             + self.object_states.len()
             + self.ship_states.len()
+            + self.volume_states.len()
     }
 
     fn count_visible(&self) -> usize {
@@ -330,6 +348,11 @@ impl FigureMgrStates {
                 .filter(|(_, c)| c.visible())
                 .count()
             + self.ship_states.iter().filter(|(_, c)| c.visible()).count()
+            + self
+                .volume_states
+                .iter()
+                .filter(|(_, c)| c.visible())
+                .count()
     }
 }
 
@@ -350,6 +373,7 @@ pub struct FigureMgr {
     object_model_cache: FigureModelCache<ObjectSkeleton>,
     ship_model_cache: FigureModelCache<ShipSkeleton>,
     golem_model_cache: FigureModelCache<GolemSkeleton>,
+    volume_model_cache: FigureModelCache<VolumeKey>,
     states: FigureMgrStates,
 }
 
@@ -372,6 +396,7 @@ impl FigureMgr {
             object_model_cache: FigureModelCache::new(),
             ship_model_cache: FigureModelCache::new(),
             golem_model_cache: FigureModelCache::new(),
+            volume_model_cache: FigureModelCache::new(),
             states: FigureMgrStates::default(),
         }
     }
@@ -404,6 +429,7 @@ impl FigureMgr {
         self.object_model_cache.clean(&mut self.col_lights, tick);
         self.ship_model_cache.clean(&mut self.col_lights, tick);
         self.golem_model_cache.clean(&mut self.col_lights, tick);
+        self.volume_model_cache.clean(&mut self.col_lights, tick);
     }
 
     pub fn update_lighting(&mut self, scene_data: &SceneData) {
@@ -600,6 +626,7 @@ impl FigureMgr {
                 item,
                 light_emitter,
                 mountings,
+                collider,
             ),
         ) in (
             &ecs.entities(),
@@ -617,6 +644,7 @@ impl FigureMgr {
             ecs.read_storage::<Item>().maybe(),
             ecs.read_storage::<LightEmitter>().maybe(),
             ecs.read_storage::<Mounting>().maybe(),
+            ecs.read_storage::<Collider>().maybe(),
         )
             .join()
             .enumerate()
@@ -794,6 +822,7 @@ impl FigureMgr {
                         &mut self.col_lights,
                         body,
                         inventory,
+                        (),
                         tick,
                         player_camera_mode,
                         player_character_state,
@@ -1670,6 +1699,7 @@ impl FigureMgr {
                             &mut self.col_lights,
                             body,
                             inventory,
+                            (),
                             tick,
                             player_camera_mode,
                             player_character_state,
@@ -1859,6 +1889,7 @@ impl FigureMgr {
                             &mut self.col_lights,
                             body,
                             inventory,
+                            (),
                             tick,
                             player_camera_mode,
                             player_character_state,
@@ -2173,6 +2204,7 @@ impl FigureMgr {
                             &mut self.col_lights,
                             body,
                             inventory,
+                            (),
                             tick,
                             player_camera_mode,
                             player_character_state,
@@ -2519,6 +2551,7 @@ impl FigureMgr {
                         &mut self.col_lights,
                         body,
                         inventory,
+                        (),
                         tick,
                         player_camera_mode,
                         player_character_state,
@@ -2620,6 +2653,7 @@ impl FigureMgr {
                         &mut self.col_lights,
                         body,
                         inventory,
+                        (),
                         tick,
                         player_camera_mode,
                         player_character_state,
@@ -2700,6 +2734,7 @@ impl FigureMgr {
                         &mut self.col_lights,
                         body,
                         inventory,
+                        (),
                         tick,
                         player_camera_mode,
                         player_character_state,
@@ -3096,6 +3131,7 @@ impl FigureMgr {
                         &mut self.col_lights,
                         body,
                         inventory,
+                        (),
                         tick,
                         player_camera_mode,
                         player_character_state,
@@ -3180,6 +3216,7 @@ impl FigureMgr {
                         &mut self.col_lights,
                         body,
                         inventory,
+                        (),
                         tick,
                         player_camera_mode,
                         player_character_state,
@@ -3356,6 +3393,7 @@ impl FigureMgr {
                         &mut self.col_lights,
                         body,
                         inventory,
+                        (),
                         tick,
                         player_camera_mode,
                         player_character_state,
@@ -3676,6 +3714,7 @@ impl FigureMgr {
                         &mut self.col_lights,
                         body,
                         inventory,
+                        (),
                         tick,
                         player_camera_mode,
                         player_character_state,
@@ -3756,6 +3795,7 @@ impl FigureMgr {
                         &mut self.col_lights,
                         body,
                         inventory,
+                        (),
                         tick,
                         player_camera_mode,
                         player_character_state,
@@ -4375,6 +4415,7 @@ impl FigureMgr {
                         &mut self.col_lights,
                         body,
                         inventory,
+                        (),
                         tick,
                         player_camera_mode,
                         player_character_state,
@@ -4613,6 +4654,7 @@ impl FigureMgr {
                         &mut self.col_lights,
                         body,
                         inventory,
+                        (),
                         tick,
                         player_camera_mode,
                         player_character_state,
@@ -4733,16 +4775,56 @@ impl FigureMgr {
                     );
                 },
                 Body::Ship(body) => {
-                    let (model, skeleton_attr) = self.ship_model_cache.get_or_create_model(
-                        renderer,
-                        &mut self.col_lights,
-                        body,
-                        inventory,
-                        tick,
-                        player_camera_mode,
-                        player_character_state,
-                        &slow_jobs,
-                    );
+                    let (model, skeleton_attr) = if let Some(Collider::Volume(vol)) = collider {
+                        let vk = VolumeKey {
+                            entity,
+                            mut_count: vol.mut_count,
+                        };
+                        let (model, _skeleton_attr) = self.volume_model_cache.get_or_create_model(
+                            renderer,
+                            &mut self.col_lights,
+                            vk,
+                            inventory,
+                            vol.clone(),
+                            tick,
+                            player_camera_mode,
+                            player_character_state,
+                            &slow_jobs,
+                        );
+
+                        let state = self
+                            .states
+                            .volume_states
+                            .entry(entity)
+                            .or_insert_with(|| FigureState::new(renderer, vk, vk));
+
+                        state.update(
+                            renderer,
+                            &mut update_buf,
+                            &common_params,
+                            state_animation_rate,
+                            model,
+                            vk,
+                        );
+
+                        break;
+                    } else if body.manifest_entry().is_some() {
+                        self.ship_model_cache.get_or_create_model(
+                            renderer,
+                            &mut self.col_lights,
+                            body,
+                            inventory,
+                            (),
+                            tick,
+                            player_camera_mode,
+                            player_character_state,
+                            &slow_jobs,
+                        )
+                    } else {
+                        println!("Cannot determine model");
+                        // No way to determine model
+                        break;
+                    };
 
                     let state = self.states.ship_states.entry(entity).or_insert_with(|| {
                         FigureState::new(renderer, ShipSkeleton::default(), body)
@@ -4847,11 +4929,12 @@ impl FigureMgr {
                 ecs.read_storage::<Health>().maybe(),
                 ecs.read_storage::<Inventory>().maybe(),
                 ecs.read_storage::<Scale>().maybe(),
+                ecs.read_storage::<Collider>().maybe(),
             )
             .join()
             // Don't render dead entities
-            .filter(|(_, _, _, _, health, _, _)| health.map_or(true, |h| !h.is_dead))
-            .for_each(|(entity, pos, _, body, _, inventory, scale)| {
+            .filter(|(_, _, _, _, health, _, _, _)| health.map_or(true, |h| !h.is_dead))
+            .for_each(|(entity, pos, _, body, _, inventory, scale, collider)| {
                 if let Some((bound, model, _)) = self.get_model_for_render(
                     tick,
                     camera,
@@ -4862,6 +4945,10 @@ impl FigureMgr {
                     false,
                     pos.0,
                     figure_lod_render_distance * scale.map_or(1.0, |s| s.0),
+                    match collider {
+                        Some(Collider::Volume(vol)) => vol.mut_count,
+                        _ => 0,
+                    },
                     |state| state.can_shadow_sun(),
                 ) {
                     drawer.draw(model, bound);
@@ -4884,19 +4971,20 @@ impl FigureMgr {
         let character_state_storage = state.read_storage::<common::comp::CharacterState>();
         let character_state = character_state_storage.get(player_entity);
 
-        for (entity, pos, body, _, inventory, scale) in (
+        for (entity, pos, body, _, inventory, scale, collider) in (
             &ecs.entities(),
             &ecs.read_storage::<Pos>(),
             &ecs.read_storage::<Body>(),
             ecs.read_storage::<Health>().maybe(),
             ecs.read_storage::<Inventory>().maybe(),
-            ecs.read_storage::<Scale>().maybe()
+            ecs.read_storage::<Scale>().maybe(),
+            ecs.read_storage::<Collider>().maybe(),
         )
             .join()
         // Don't render dead entities
-        .filter(|(_, _, _, health, _, _)| health.map_or(true, |h| !h.is_dead))
+        .filter(|(_, _, _, health, _, _, _)| health.map_or(true, |h| !h.is_dead))
         // Don't render player
-        .filter(|(entity, _, _, _, _, _)| *entity != player_entity)
+        .filter(|(entity, _, _, _, _, _, _)| *entity != player_entity)
         {
             if let Some((bound, model, col_lights)) = self.get_model_for_render(
                 tick,
@@ -4908,6 +4996,10 @@ impl FigureMgr {
                 false,
                 pos.0,
                 figure_lod_render_distance * scale.map_or(1.0, |s| s.0),
+                match collider {
+                    Some(Collider::Volume(vol)) => vol.mut_count,
+                    _ => 0,
+                },
                 |state| state.visible(),
             ) {
                 drawer.draw(model, bound, col_lights);
@@ -4953,6 +5045,7 @@ impl FigureMgr {
                 true,
                 pos.0,
                 figure_lod_render_distance,
+                0,
                 |state| state.visible(),
             ) {
                 drawer.draw(model, bound, col_lights);
@@ -4980,6 +5073,7 @@ impl FigureMgr {
         is_player: bool,
         pos: vek::Vec3<f32>,
         figure_lod_render_distance: f32,
+        mut_count: usize,
         filter_state: impl Fn(&FigureStateMeta) -> bool,
     ) -> Option<FigureModelRef> {
         let body = *body;
@@ -5010,6 +5104,7 @@ impl FigureMgr {
             object_model_cache,
             ship_model_cache,
             golem_model_cache,
+            volume_model_cache,
             states:
                 FigureMgrStates {
                     character_states,
@@ -5027,6 +5122,7 @@ impl FigureMgr {
                     golem_states,
                     object_states,
                     ship_states,
+                    volume_states,
                 },
         } = self;
         let col_lights = &*col_lights_;
@@ -5255,22 +5351,43 @@ impl FigureMgr {
                         ),
                     )
                 }),
-            Body::Ship(body) => ship_states
-                .get(&entity)
-                .filter(|state| filter_state(*state))
-                .map(move |state| {
-                    (
-                        state.bound(),
-                        ship_model_cache.get_model(
-                            col_lights,
-                            body,
-                            inventory,
-                            tick,
-                            player_camera_mode,
-                            character_state,
-                        ),
-                    )
-                }),
+            Body::Ship(body) => {
+                if body.manifest_entry().is_some() {
+                    ship_states
+                        .get(&entity)
+                        .filter(|state| filter_state(*state))
+                        .map(move |state| {
+                            (
+                                state.bound(),
+                                ship_model_cache.get_model(
+                                    col_lights,
+                                    body,
+                                    inventory,
+                                    tick,
+                                    player_camera_mode,
+                                    character_state,
+                                ),
+                            )
+                        })
+                } else {
+                    volume_states
+                        .get(&entity)
+                        .filter(|state| filter_state(*state))
+                        .map(move |state| {
+                            (
+                                state.bound(),
+                                volume_model_cache.get_model(
+                                    col_lights,
+                                    VolumeKey { entity, mut_count },
+                                    inventory,
+                                    tick,
+                                    player_camera_mode,
+                                    character_state,
+                                ),
+                            )
+                        })
+                }
+            },
         } {
             let model_entry = model_entry?;
 
diff --git a/voxygen/src/scene/figure/volume.rs b/voxygen/src/scene/figure/volume.rs
new file mode 100644
index 0000000000..e43f9517d1
--- /dev/null
+++ b/voxygen/src/scene/figure/volume.rs
@@ -0,0 +1,100 @@
+use super::{
+    cache::FigureKey,
+    load::{BodySpec, BoneMeshes},
+    EcsEntity,
+};
+use common::{
+    assets,
+    comp::ship::figuredata::VoxelCollider,
+    figure::{Cell, Segment},
+    vol::ReadVol,
+};
+use std::{convert::TryFrom, sync::Arc};
+use vek::*;
+
+#[derive(Copy, Clone, PartialEq, Eq, Hash)]
+pub struct VolumeKey {
+    pub entity: EcsEntity,
+    pub mut_count: usize,
+}
+
+impl<'a> From<&'a Self> for VolumeKey {
+    fn from(this: &Self) -> Self { *this }
+}
+
+impl anim::Skeleton for VolumeKey {
+    type Attr = Self;
+    type Body = Self;
+
+    const BONE_COUNT: usize = 4;
+
+    //#[cfg(feature = "use-dyn-lib")]
+    // TODO
+
+    fn compute_matrices_inner(
+        &self,
+        base_mat: anim::vek::Mat4<f32>,
+        buf: &mut [anim::FigureBoneData; anim::MAX_BONE_COUNT],
+        _: Self::Body,
+    ) -> anim::Offsets {
+        let scale_mat = anim::vek::Mat4::scaling_3d(1.0 / 11.0);
+
+        let bone = base_mat * scale_mat; // * anim::vek::Mat4::<f32>::identity();
+
+        *(<&mut [_; Self::BONE_COUNT]>::try_from(&mut buf[0..Self::BONE_COUNT]).unwrap()) = [
+            anim::make_bone(bone),
+            anim::make_bone(bone),
+            anim::make_bone(bone),
+            anim::make_bone(bone),
+        ];
+
+        anim::Offsets {
+            lantern: None,
+            mount_bone: anim::vek::Transform::default(),
+        }
+    }
+}
+
+impl BodySpec for VolumeKey {
+    type Extra = Arc<VoxelCollider>;
+    type Manifests = ();
+    type Spec = ();
+
+    fn load_spec() -> Result<Self::Manifests, assets::Error> { Ok(()) }
+
+    fn is_reloaded(_: &mut Self::Manifests) -> bool { false }
+
+    fn bone_meshes(
+        _: &FigureKey<Self>,
+        _: &Self::Manifests,
+        collider: Self::Extra,
+    ) -> [Option<BoneMeshes>; anim::MAX_BONE_COUNT] {
+        println!("Generating segment...");
+        [
+            Some((
+                Segment::from_fn(collider.volume().sz, (), |pos| {
+                    match collider.volume().get(pos).unwrap().get_color() {
+                        Some(col) => Cell::new(col, false, false, false),
+                        None => Cell::Empty,
+                    }
+                }),
+                -collider.volume().sz.map(|e| e as f32) / 2.0,
+            )),
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+        ]
+    }
+}
diff --git a/voxygen/src/scene/mod.rs b/voxygen/src/scene/mod.rs
index 3f69f0dfe6..336356d9d5 100644
--- a/voxygen/src/scene/mod.rs
+++ b/voxygen/src/scene/mod.rs
@@ -1198,7 +1198,9 @@ impl Scene {
                         let hb_ori = [ori.x, ori.y, ori.z, ori.w];
                         self.debug.set_context(*shape_id, hb_pos, color, hb_ori);
                     },
-                    comp::Collider::Voxel { .. } | comp::Collider::Point => {
+                    comp::Collider::Voxel { .. }
+                    | comp::Collider::Volume(_)
+                    | comp::Collider::Point => {
                         // ignore terrain-like or point-hitboxes
                     },
                 }
diff --git a/voxygen/src/scene/simple.rs b/voxygen/src/scene/simple.rs
index 7381a920ac..e41c919fe1 100644
--- a/voxygen/src/scene/simple.rs
+++ b/voxygen/src/scene/simple.rs
@@ -320,6 +320,7 @@ impl Scene {
                     &mut self.col_lights,
                     body,
                     inventory,
+                    (),
                     scene_data.tick,
                     CameraMode::default(),
                     None,