resourcepack.ai
DOCS

3D models

3D Engine

3D Engine puts real 3D models into vanilla Minecraft. Drop a .glb, .gltf or .obj file into a folder on your server, run /3d reload, and it is in the game: the actual mesh, with its own textures and animations, not a blocky remake of it. Furniture, statues, vehicles, props and creatures, anything you can download from Sketchfab or export from Blender.

The plugin bakes every file into item models drawn by core shaders, builds a resource pack from them and sends it to players as they join. Players install nothing.

Requirements#

  • Spigot or Paper on Minecraft 26.3, on Java 21. The shaders and the pack format are that version's, so the players need to be on 26.3 too.
  • On Paper, models that walk use Paper's mob pathfinding; on Spigot they use a path search of the engine's own. Everything else is the same on both.

Installing#

  1. 1

    Get the jar

    Download 3DEngine.jar, or get it from SpigotMC. That link is always the current release.

  2. 2

    Drop it in and start the server

    3DEngine.jar into plugins/. On first start it makes:

    plugins/3DEngine/
      config.yml
      models/     ← your 3D files
      effects/    ← shader effects, optional
      cache/      ← baked models, kept between restarts
      pack.zip    ← the resource pack it built
  3. 3

    Let players reach the pack

    The plugin serves its pack over HTTP on port 8163. Open that port, and set pack.url in config.yml to the address players reach it on:

    pack:
      url: "http://play.example.com:8163"

    Left empty, players are sent this server's own IP, which is rarely what they can reach. The console warns you on startup when it is empty.

  4. 4

    Add a model

    Put a model file in plugins/3DEngine/models/ and run /3d reload. Then look at a block and /3d place <model>.

How players get the pack#

Every /3d reload builds a new pack, writes it to plugins/3DEngine/pack.zip and serves it from the plugin's own web server. Players get it as they join, and everybody online is sent the new one whenever a reload changes it. The pack's address includes its hash, so a client never keeps a stale copy.

KeyDefaultWhat it does
pack.hosttrueServe the pack from this server over HTTP
pack.port8163The port it is served on
pack.bindemptyThe address to listen on. Empty is all of them
pack.urlemptyThe address players download it from
pack.sendtrueSend it to players as they join
pack.requiredfalseKick players who decline it
pack.promptemptyThe message on the download prompt
pack.format[97, 1]The pack format. Leave it alone unless you know why
pack.description3D Engine modelsThe pack's description

Already sending your own pack? Set pack.send: false and merge plugins/3DEngine/pack.zip into yours after every reload. /3d pack re-sends the engine's pack to you, for checking it arrived.

The pack replaces some of the game's shaders

Models are drawn by core shaders, so the pack replaces the game's block, entity, item and terrain shaders. If you add any shader effects, it also replaces the text shader.

Only one pack can replace a given shader. If another pack on the same client replaces any of these, whichever is higher in the list wins and the other's drawing silently breaks. That includes other plugins that draw with the text shader, such as RP Engine's shader objects. The models' own item models are in a 3dengine namespace and override nothing vanilla.

Adding models#

Formats: .glb, .gltf and .obj. An .obj file's .mtl and textures, and a .gltf file's .bin and textures, go beside it.

Where: anywhere under plugins/3DEngine/models/, in folders if you like. A model's name is its path in that folder, lower case, without the extension: models/furniture/armchair.glb is furniture/armchair.

Sketchfab downloads go in as they come. Drop the .zip into the models folder and the next reload unpacks it into a folder of the same name. A file called scene.gltf, which is what every Sketchfab download contains, takes its folder's name instead, and its .yml is scene.yml beside it. Delete the unpacked folder to unpack the zip again.

Baking is cached. A reload only re-bakes a model whose file, .yml or detail settings changed, so a restart with nothing new is quick. Baking runs off the main thread, and /3d reload reports how many models and effects are ready, any that failed and why, and how big the pack came out.

/3d list shows every model with its height and states, and any that failed. /3d info <model> adds its width, points, seats, hitboxes and what it costs the client.

Placing models#

How
/3d place <model> [scale]Puts it on the block you look at, facing you
/3d give <model> [player]A model item: right-click a block to put the model there

Both are saved with the world and come back after a restart. The model item shows the model itself as its icon and is not used up, so one item places as many as you like. A player without 3dengine.place can hold one, but using it places nothing and tells them they need that permission.

Changing a placed model. Look at it (within eight blocks) and:

Command
/3d removeTakes it away
/3d scale <n>Resizes it. 1 is its normal size
/3d rotate <degrees>Turns it
/3d state <state>Plays one of its animation states
/3d sit [seat]Sits you in it, in a named seat or the nearest free one

Sitting down. Right-clicking a model that has seats sits you in the nearest free one, if you have 3dengine.ride (everyone does by default). Sneak to get off. Right-clicking while sneaking never sits you down.

The model's .yml#

Every model file can have a .yml of the same name beside it: armchair.glb and armchair.yml. Every key is optional. A file with no .yml comes in at its own size, with one looping state per animation in the file (or a single still one if it has none).

Positions in blocks (seats, hitboxes) are measured from the middle of the model's base: x to its right, y up, z forward.

Size and placement#

height: 1.8            # resize to this many blocks tall
# scale: 0.5           # or: blocks per unit in the file (ignored when height is set)
yaw: 90                # turn it so its front faces the way it is placed
offset: [0, 0, 0]      # move it, in blocks, after resizing
hide: [Ground, Camera] # leave out parts of the file by node or bone name
icon-scale: 1          # how big it is drawn as an item icon; fitted if left out

hide is for the extras a downloaded file often carries: a ground plane, a backdrop, a camera rig. Everything under a hidden node goes with it.

States and animation#

A state is an animation the model can settle into and loop: idle, walk, open. Without a states: block every clip in the file becomes one, named after the clip in lower case.

default-state: idle
states:
  idle: {clip: Idle, fps: 10, syncs: 2}
  walk: {clip: Walk, fps: 20, wait: true}
  sit:  {clip: Idle, pose: {Body: {rotate: [-40, 0, 0], translate: [0, -0.3, 0]}}}
  shuffle: {clip: Walk, blend: {clip: Idle, weight: 0.45}}
transition-frames: 6
transitions:
  - idle <-> walk
  - {from: idle, to: sit, frames: 12}
KeyDefaultWhat it does
clipThe animation in the file. A state with no clip is a still pose
fps10Frames baked per second. More is smoother and makes a bigger pack
syncs1How many evenly spaced points in the loop it can leave from
sync-atOr the exact ticks it can leave from
waitfalseWait for a sync point before leaving, rather than cutting at the nearest
poseBones turned (degrees) or moved on top of the clip
blendMix in another clip: clip, weight (0 to 1), and at (seconds into it)

default-state is the state a model starts in. Left out, it is idle if there is one, or the first state.

A transition is a short blend from one state into another, baked from the pose the first leaves on. a -> b is one way, a <-> b both ways, and the map form takes frames and both-ways: true. Each lasts transition-frames ticks unless it says otherwise. Without a transition, a state change cuts straight across.

Animations from another file. animations-from: other.glb drives this model with that file's clips, matched by bone name, and the other file is not loaded as a model of its own. retarget: {translate: [Body]} limits which bones' movement is carried over (by default, all of them), and rebind: [Bone] re-weights the vertices of a bone nothing animates onto their neighbours. legs: lists IK chains, {chain: [Upper, Lower], target: IKFoot}, that keep feet planted.

Points#

Named places that follow a bone through every animation, such as a mouth or a hand, for plugins to hang things on:

points:
  mouth: {bone: Head, at: [0, 2.1, 2.4]}

Looking at players#

look:
  bone: Head           # only the head turns, on its neck; leave out to turn the whole model
  with: [Jaw]          # more bones that turn with the head
  yaw: 70              # the most it turns either way, in degrees
  pitch: 35
  range: 8             # blocks
  speed: 9             # degrees per tick
  states: [idle, sit]  # the states it looks in; all if left out

look: true turns the whole model with the defaults above, and look: Head is the same with a head bone. A turning head is drawn as a second model, so it costs one more item model per animation.

Rigs often hang the jaw, tongue or ears beside the head bone rather than under it. Any bone beside it whose vertices sit within the head's own space turns with the head anyway (the bake log names them); with: adds any it misses.

Seats and driving#

seats:
  driver: {at: [-0.4, 0.5, 0.2]}
  passenger: {at: [0.4, 0.5, 0.2], bone: Body, pose: standing}
drive:
  seat: driver         # whose rider steers; the first seat if left out
  speed: 0.2           # blocks per tick
  sprint: 0.32
  back: 0.08
  turn: 7              # degrees per tick
  jump: 0.42           # 0 = it can't jump
  brain: horse         # the invisible mob that carries it; picked by size if left out

A seat's at is where the rider's hips go, or their feet with pose: standing. With a bone it rides that bone through every animation. A rider can click past the model they are sitting in.

With drive:, whoever sits in the driving seat steers: the model goes where they look while they hold forward. drive: true uses the defaults above. The brain is an invisible mob under the model that does the walking, and its size decides what gaps the model fits through.

If riders sit a little high or low on your server, seats.height-offset in config.yml moves every seat at once.

Hitboxes and solid models#

hitboxes:
  - {at: [0, 0, 0.6], width: 1, height: 1.2}
solid: true

Hitboxes are what make a model clickable, and they are fitted to it unless you write your own: one box, or a row of up to six along a long model, so a car can be clicked at either end. hitbox: {at, width, height} is a single box of your own, and hitbox: false makes the model unclickable.

solid: true fills a placed model's boxes with barrier blocks, so nobody walks through a statue. Barriers go only in empty blocks, never where a rider sits, and are taken away when the model is removed, moved or resized. A model that moves is never solid.

Held as an item#

item: true lets the model be held as an item. To set how it sits in each view, give it a map of display contexts, as a block model's display does:

item:
  thirdperson_righthand: {rotation: [0, 90, 0], translation: [0, 2, 1], scale: 0.6}
  firstperson_righthand: {rotation: [0, 90, 0], scale: 0.5}

Walking#

locomotion: tells a model that walks which states to play and when:

locomotion:
  idle: idle
  walk: walk
  run: run
  turn: turn          # turning on the spot
  walk-above: 0.035   # blocks per tick
  run-above: 0.13

Each name defaults to the state of that name if the model has one. Plugins walk models through the API.

Any other key in the file is kept for plugins to read, so an addon can keep its own settings beside a model.

Detail and pack size#

Every model is simplified to a number of triangles and its texture to a width when it is baked. More is sharper, and makes the pack bigger and the game heavier for players. An animated model stores its whole mesh once per frame and its texture once per animation, so the texture is usually what makes a pack big.

WhereWins over
/3d quality <model> <triangles> [texture width]everything
triangles: and texture-width: in the model's .ymlthe server's defaults
quality.triangles and quality.texture-width in config.ymlnothing: 20,000 and 512

Nothing ever keeps more than quality.max-triangles (200,000) and quality.max-texture-width (2,048).

/3d quality with no arguments shows the defaults and every per-model setting; with a model it shows what that model has. Setting one re-bakes it straight away and is kept in config.yml under quality.models, so a plugin that re-copies its own model files cannot undo it. It reads config.yml from disk before saving, so anything you have changed in the file by hand is kept. /3d quality <model> reset takes it back to its own settings.

Shader effects#

An effect is a small GLSL program drawn into the world: a fireball, a puff of flame, a black hole. Put .glsl files in plugins/3DEngine/effects/, run /3d reload, and /3d effect <name> [size] puts one down where you look, size blocks across. /3d effect alone lists them, and /3d effect clear takes away every one put down by the command. They are not saved, so a restart clears them too.

An effect file defines one function, from a ray to a colour:

vec4 object(vec3 ro, vec3 rd, float t0, float t1, float time, vec3 light, inout float depth) {
    // ...
}

It is checked before it reaches anybody's graphics card, and refused if it breaks a rule: no while or do loops, and every for loop needs a literal bound of at most 160; no preprocessor directives, uniforms, samplers, textures or gl_ built-ins; no main() and no discard (return an alpha of 0 instead); at most 20,000 characters. There can be up to 255 effects. The reload names any that were refused and why.

Effects replace the text shader

The text shader is only replaced when the effects folder has at least one effect in it. Leave the folder empty if another plugin on your server needs that shader.

Commands#

/3d, or /engine3d. Each subcommand has its own permission, and /3d alone lists the ones you may use.

CommandWhat it does
/3d listEvery model, its height and states, and any that failed to bake
/3d place <model> [scale]Places a model on the block you look at, facing you. Saved with the world
/3d give <model> [player]A model item that places the model where it is used
/3d removeTakes away the model you look at
/3d scale <n>Resizes the model you look at
/3d rotate <degrees>Turns the model you look at
/3d state <state>Plays a state on the model you look at
/3d sit [seat]Sits you in the model you look at
/3d info [model]Size, states, points, seats, hitboxes, and what it costs the client
/3d quality [model] [triangles|reset] [texture width]Shows the detail settings, or sets one model's and re-bakes it
/3d effect <name> [size]Puts down a shader effect. /3d effect clear takes them away
/3d reloadRe-reads config.yml, re-bakes the models folder and re-sends the pack
/3d packRe-sends you the pack

From the console, a player's name at the end of the command runs it as that player: /3d effect fire 2 Steve. The console's own permissions are what count.

Permissions#

One node per subcommand, so you can hand out exactly the ones you want: whoever may resize a model is not necessarily whoever may re-bake the whole folder. Somebody without a command's node is told which one they need, and tab completion only offers what they may use.

3dengine.admin is a parent granting every command and placing with a model item, for a server that wants them all in one line. Nothing checks it directly.

NodeDefaultWhat it allows
3dengine.adminopEvery /3d command, and placing models with a model item
3dengine.listop/3d list, every model and any that failed to load
3dengine.place.commandop/3d place, putting any model down where you look
3dengine.giveop/3d give, a model item for yourself
3dengine.give.othersop/3d give <model> <player>, a model item for somebody else (on top of 3dengine.give)
3dengine.removeop/3d remove, taking away the model you look at
3dengine.scaleop/3d scale, resizing the model you look at
3dengine.rotateop/3d rotate, turning the model you look at
3dengine.stateop/3d state, switching the animation of the model you look at
3dengine.sitop/3d sit, sitting in a seat of the model you look at, by name
3dengine.infoop/3d info, a model's size, states, seats and hitboxes
3dengine.qualityop/3d quality, seeing or changing how detailed a model is baked (re-bakes it)
3dengine.effectop/3d effect, putting down or clearing shader effects
3dengine.reloadop/3d reload, baking the models folder again
3dengine.packop/3d pack, having the resource pack sent to you again
3dengine.placeopPlacing models with a model item (from /3d give)
3dengine.rideeveryoneSitting in a model's seats by right-clicking it
3dengine.updateopBeing told on joining when a newer 3D Engine is on SpigotMC

3dengine.admin grants everything above it in the table and 3dengine.place, but not 3dengine.ride or 3dengine.update: riding is everyone's already, and update notices are a preference rather than a power.

Why /3d place is 3dengine.place.command. 3dengine.place already meant using a model item, and a server that hands those items to players should not find that it also lets them put down any model they like, anywhere.

3D Engine checks ordinary Bukkit permissions, so LuckPerms and anything else that provides them works:

/lp group builder permission set 3dengine.give true
/lp group builder permission set 3dengine.place true
/lp group admin permission set 3dengine.admin true

Configuration#

plugins/3DEngine/config.yml. Edit it and run /3d reload, and the changes take effect, except for pack.host, pack.port and pack.bind: the pack server is started with the server, so those need a restart, and the reload tells you when one of them has changed. If the file has a mistake in it and can't be read, the reload says so and keeps the settings it already had, and /3d quality refuses to save rather than write over it. Saving keeps the file's comments.

pack:
  host: true
  port: 8163
  bind: ""
  url: ""                # e.g. http://play.example.com:8163
  send: true
  required: false
  prompt: ""
  format: [97, 1]
  description: "3D Engine models"
 
quality:
  triangles: 20000
  texture-width: 512
  max-triangles: 200000
  max-texture-width: 2048
  models: {}             # per model, written by /3d quality
 
seats:
  height-offset: 0.0     # raise (or, negative, lower) every seated rider
 
telemetry: true
update-check: true

Two switches concern what the plugin connects to:

  • update-check asks SpigotMC every six hours whether a newer 3D Engine is out, and if so tells the console and anybody with 3dengine.update.
  • telemetry is an anonymous usage count: every 30 seconds, a random id the plugin made up the first time it started, how many players are online, and the names of installed plugins that depend on 3D Engine, sent to resourcepack.ai. No address, server name, player names or content.

Set either to false to turn it off.

Developer API#

Add depend: [3DEngine] to your plugin.yml, compile against 3DEngine.jar as a compile-only dependency, and call Engine3D.get():

Engine3D engine = Engine3D.get();
engine.whenReady(() -> {
    ModelInstance chair = engine.place("furniture/armchair", location); // saved with the chunk
    chair.mount(player, null);              // into the nearest free seat
    chair.lookAt(player);                   // or lookAround(), lookAway()
 
    ModelInstance dog = engine.spawn("dog", location);                  // not saved
    Mover legs = engine.mover(dog, EntityType.WOLF);
    legs.follow(player);
});

whenReady runs once every model is baked, and again after every /3d reload, so a plugin can respawn what it drew against the new models. A plugin can copy its own model files into engine.modelsFolder() in its onEnable: the first bake waits for the first tick.

Engine3DModels and instances; spawn (not saved) and place (saved); mover; item(model, state), an item that looks like the model; effect, particle and the effect variants
ModelA baked model: its states, points, seats, height, and the extra keys from its .yml
ModelInstanceOne model in the world: change state through its transitions, find where a point is on any frame, scale, seat riders, look at things, turn hitboxes on and off
MoverLegs for a model: an invisible mob that follows, goes to a place, faces, and plays walk, run and turn to match its speed
EffectInstance, ParticleShader effects placed in the world, and ones the engine flies

Engine3D.instance(entity) finds the model any of its entities belongs to: its display, its head, a hitbox, a seat or its brain.

Events#

All in ai.resourcepack.engine3d.api.event:

EventWhenCancel to
ModelInteractEventA player right-clicks a modelStop the default (sitting down)
ModelDamageEventA player punches it, or a projectile or mob hits itTell later listeners
ModelMountEventSomething is about to sit in a seatKeep them off
ModelDismountEventA rider is getting offKeep them on, when isCancellable()
ModelStateChangeEventA new state's loop starts
ModelSpawnEvent / ModelRemoveEventA model is put down / taken away

A model has no health of its own: what a hit does is up to you. 3D Pets is an addon built this way, on nothing but this API.

Credits#

The meshes are drawn by obj³, after Godlander's objmc. Its core shaders ship in the pack, with their licence.