This guide describes the public API in the current source version 0.9.4. The mod is an experimental checkpoint: the API compiles and its storage rules are covered by automated checks. Most of it has not been exercised by another mod in a running game yet; layer access and local layers were checked in single-player test clients.
FFEntityPainting — Modding API
Use this guide when integrating another mod with FFEntityPainting (ffentitypainting).
| Minecraft | 1.21.1 and 1.21.11 |
| Loader | NeoForge (21.1.x, 21.11.x) and Fabric |
| Mod ID | ffentitypainting |
| API packages | com.fyxe.ffentitypainting.api, .api.access, .api.event, .api.client, .api.shape |
| Source version | 0.9.4 |
| Maven | One artifact per target on the Fyxe Gitea registry: com.fyxe.ffentitypainting:ffentitypainting-1.21.1-neoforge:0.9.1, ...-1.21.1-fabric, ...-1.21.11-neoforge and ...-1.21.11-fabric. The 1.21.1 NeoForge jar is also published as com.fyxe.ffentitypainting:ffentitypainting:0.9.1, the coordinate older consumers use. |
Dependency (optional), NeoForge neoforge.mods.toml:
[[dependencies.yourmod]]
modId="ffentitypainting"
type="optional"
versionRange="[0.9.0,)"
ordering="NONE"
side="BOTH"
Fabric fabric.mod.json (put it under suggests for an optional dependency, or depends for a required one):
"depends": { "ffentitypainting": ">=0.9.0" }
Use 0.9.1 as the lower bound if you use the opt-out API (excludeEntities, isPaintable, UNPAINTABLE, excludeModel(s)), or 0.8.0 if you use nothing from 0.9.0 (layer access, local layers). The unpaintable tag file needs no dependency at all. Compile against the artifact for your target without bundling it (players install this mod separately): compileOnly on NeoForge, modCompileOnly on Fabric.
// NeoForge
compileOnly("com.fyxe.ffentitypainting:ffentitypainting-1.21.11-neoforge:0.9.2")
// Fabric
modCompileOnly("com.fyxe.ffentitypainting:ffentitypainting-1.21.11-fabric:0.9.2")
Check that the mod is loaded (FFUtilities' Mods.isLoaded("ffentitypainting"), or your loader's own check) before touching API classes, and keep those calls in a separate class so your mod still loads without it. Never reference api.client classes from common or server code.
Pick your integration
| You want to… | Use |
|---|---|
| Add a brush, spray can, stencil or dyeable paint | PaintTool on your item (onStamp to wear it out) |
| Custom brush shapes, fades or gradients from the server | PaintEditor.transform / transformRect |
| Paint or tint entities from game logic (team colors, status marks, cosmetics) | EntityPaintingAPI.edit with a locked layer |
| Team layers: choose who sees a layer and who may paint it | PaintEditor.setViewAccess / setPaintAccess with LayerAccess, your own rules through LayerRules.register |
| Give layers players create a team or owner | setNewLayerView / setNewLayerPaint on PlayerStamp / PlayerLayerEdit |
| Paint only one player sees (particles, previews, hints) | EntityPaintingClientAPI.editLocal / paintLocal |
| Block painting in claims, arenas or for some mobs | Cancel PaintEvent.PlayerStamp / PlayerLayerEdit |
| React to paint (quests, logging, achievements) | PaintEvent.Changed |
| Read paint back (renderers, maps, exports) | EntityPaintingAPI.view |
| Refresh client-side effects when paint changes | PaintUpdatedEvent |
| Keep paint through a mob conversion or clone | EntityPaintingAPI.copyPaint |
| Know where each paintable spot sits on the body, on the server (paint the feet, the side facing a hit) | BodyShapes |
| Keep your mobs from being painted | The ffentitypainting:unpaintable entity type tag (no code), or EntityPaintingAPI.excludeEntities (see Opting mobs out) |
| Paint from a keybind or menu | EntityPaintingClientAPI.requestPaint |
Core concepts
- Synced and local layers. Synced layers are server-authoritative: edits apply only on the logical server, and on a client
edit,copyPaintandclearPaintdo nothing and returnfalse. Reads work on both sides, since clients hold a synced copy for every tracked entity (only the layers they may see). Local layers live on one client only and are edited there (see Local layers). - Layer access. Each synced layer has a view access (who is sent it at all) and a paint access (who may paint and edit it with a tool or the layer manager). Both default to everyone. See Who sees and paints a layer.
- Surfaces and parts. Paint is stored per part of a model, per surface. A surface is one model drawn with one texture. A part is one named
ModelPart(or GeckoLib bone), or one cube of a multi-cube part. Every living entity is paintable unless something opts it out, modded ones included. The storage key isEntityPaintingAPI.partKey(surfaceId, partName). - Parts appear on first paint. Only the client sees exact geometry, so a part normally exists after a player first paints it. A mod that knows its own model can create parts on the server with
PaintEditor.establishPart. - Layers are composited bottom to top, each with a
BlendMode(22 of them since 0.9.4, listed below;BlendMode.byName("screen")parses one), an opacity and a visibility flag. A locked layer can only be changed through the API. Players see it but cannot select, paint, clear, edit or move it, their own layers cannot be moved past it, and it does not count toward the server'smaxLayers. A layer a player adds goes just below the locked layers at the top of the stack, so overlays you keep on top stay there. - Colors are straight-alpha ARGB
ints. APixelModedecides how a color meets what a layer already holds:BLEND(source-over, strokes build up),REPLACE(exact color including alpha) orERASE(alpha is how much coverage to remove), or one of the 14 modes added in 0.9.4 (below). - Hard limits apply to everyone:
MAX_LAYERS16,MAX_PARTS64,MAX_TOTAL_PIXELS196,608 texels per entity,MAX_SIDE256 texels per canvas side,MAX_BRUSH_RADIUS7.
Custom paint tools
Implement PaintTool on any Item. It gets the built-in behavior: right-click a living entity in first person to paint the texel under the crosshair, sneak-right-click for the layer manager. Every method gets the stack, so the color can live in a data component.
import com.fyxe.ffentitypainting.api.PaintTool;
import com.fyxe.ffentitypainting.api.PixelMode;
public class SprayCanItem extends Item implements PaintTool {
public SprayCanItem(Properties p) { super(p); }
@Override public int paintColor(ItemStack stack) {
// Half-strength by default; the painter's Brush Opacity scales it further.
return 0x80000000 | (stack.getOrDefault(MyComponents.SPRAY_RGB, 0xFF8800) & 0xFFFFFF);
}
@Override public PixelMode pixelMode(ItemStack stack) { return PixelMode.BLEND; }
@Override public int brushRadius(ItemStack stack, int configuredRadius) {
return Math.min(configuredRadius + 2, 7); // a wider nozzle than the server default
}
@Override public void onStamp(ItemStack stack, ServerPlayer player, LivingEntity target, InteractionHand hand) {
stack.hurtAndBreak(1, player, LivingEntity.getSlotForHand(hand)); // the can runs dry
}
}
The server reads these methods when it applies a stroke, so they must give the same answer on both sides. For an eraser, return an alpha of the strength you want and PixelMode.ERASE; RGB is ignored. onStamp runs on the server after each stroke that changed paint, once clients have been sent the change; it does not run for cancelled strokes, strokes that changed nothing, or layer manager edits.
Blend modes and pixel modes (0.9.4)
Two enums control how colors combine. They are separate on purpose: a PixelMode decides how a brush color meets the texel a layer already holds, and a BlendMode decides how the finished layer meets everything beneath it, including the mob's own texture. Both have byName and a serialized name that commands, saves and PaintEvent use. BlendMode.blend(src, dst, opacity) and PixelMode.apply(dst, src) are pure functions of ARGB ints, so you can call them for previews, tests or your own composites.
| BlendMode | Result |
|---|---|
NORMAL, MULTIPLY, ADDITIVE, SCREEN, OVERLAY | The original five |
DARKEN, LIGHTEN | Per-channel minimum or maximum |
COLOR_DODGE, COLOR_BURN, LINEAR_BURN | Brighten or darken by the layer's color, with more punch than screen and multiply |
HARD_LIGHT, SOFT_LIGHT | The layer acts as a light: strong (hard) or diffuse (soft) |
DIFFERENCE, EXCLUSION, SUBTRACT, DIVIDE | Arithmetic on the channels, clamped to 0 to 255 |
HUE, SATURATION, COLOR, LUMINOSITY | Take one of hue, saturation, both, or brightness from the layer and the rest from what is beneath. COLOR recolors without flattening shading |
ERASE | Removes coverage beneath wherever the layer has paint; the layer's own color is ignored |
MASK | Keeps what is beneath only where the layer has paint and removes it everywhere else on the part, like a clipping mask |
The colour modes follow the W3C compositing definitions. As before, a colour mode only mixes where something beneath has coverage; over a transparent backdrop the layer shows as itself. ERASE and MASK change alpha instead (BlendMode.affectsAlpha()), so on a mob drawn with a cutout RenderType they cut holes in the mob's own texture, and a MASK layer hides everything beneath it on every part it has a canvas for. next() and previous() step through the modes.
| PixelMode | Result |
|---|---|
BLEND, REPLACE, ERASE | The original three |
ATOP | Recolors only texels that already hold paint, mixing by the color's alpha and keeping each texel's own alpha |
BEHIND | Paints only behind what is there; existing paint stays on top |
MULTIPLY, SCREEN, ADD, SUBTRACT, DARKEN, LIGHTEN, DIFFERENCE | The blend mode of the same name applied to the texel at full opacity, with the color's alpha as strength. On an empty texel they paint the color as itself |
DODGE, BURN | Lighten toward white or darken toward black by the color's alpha; RGB is ignored |
INVERT | Inverts existing paint by the color's alpha; RGB is ignored |
SATURATE, DESATURATE | Push existing paint away from grey or pull it toward grey by the color's alpha; RGB is ignored |
PixelMode.existingOnly() is true for the modes that can never add coverage to an empty texel: ATOP, DODGE, BURN, INVERT, SATURATE, DESATURATE and ERASE. Every mode works with PaintTool.pixelMode, PaintEditor.stamp, fill, fillRect, setPixel, writeCanvas, EntityPaintingAPI.fillAllParts, EntityPaintingClientAPI.paintLocal and PaintEvent.PlayerStamp.setMode. A tool that sharpens or recolors without adding paint:
public final class TintBrush extends Item implements PaintTool {
@Override public int paintColor(ItemStack stack) { return 0xC0FF8800; }
@Override public PixelMode pixelMode(ItemStack stack) { return PixelMode.ATOP; } // only recolors paint that exists
}
// Make a layer cast a color over the mob's own texture without flattening its shading
editor.setBlendMode(layerId, BlendMode.COLOR);
// Or cut a window through everything beneath it
editor.setBlendMode(maskLayerId, BlendMode.ERASE);
Editing paint from the server
EntityPaintingAPI.edit hands you a PaintEditor working on a private copy. If the callback returns normally and something changed, the whole batch commits as one revision and fires one PaintEvent.Changed. If it throws, nothing is saved. A batch that only changes texels of parts clients already know sends just those canvases right away. Anything else (layers, layer settings, new parts) sends one full snapshot at the end of the tick, however many batches touched the entity. Calling edit for the same entity from inside its own batch throws IllegalStateException; make every change in the outer batch.
import com.fyxe.ffentitypainting.api.EntityPaintingAPI;
import com.fyxe.ffentitypainting.api.PixelMode;
// Tint every painted part with a translucent team color on a layer players can't touch.
EntityPaintingAPI.edit(mob, editor -> {
var layer = editor.getOrCreateLayer("mymod:team", true);
if (layer.isEmpty()) return; // 16 layers already
for (var part : editor.parts()) {
editor.fill(layer.get(), part.key(), 0x60FF2020, PixelMode.REPLACE);
}
});
Common PaintEditor calls:
| Method | Notes |
|---|---|
addLayer(name[, locked]), getOrCreateLayer(name, locked) | Return Optional<String> layer ID; empty at 16 layers. New layers go on top. Names are 1 to 64 characters. |
removeLayer, moveLayer(id, index), rename | Index 0 is the bottom. Removing a layer forgets parts no other layer paints. |
setOpacity(id, 0..1), setVisible, setBlendMode, setLocked | Return true when the value changed. |
stamp(layer, part, u, v, argb, radius, mode) | Round brush at part-local u, v (0 to 1 across the part). |
stampTexture(layer, part, texU, texV, argb, radius, mode) | Same, at a UV on the entity's original texture inside the part's rectangle. |
setPixel, fillRect, fill, writeCanvas | Texel coordinates are local to the part's canvas. writeCanvas takes width * height row-major values. |
transform(layer, part, fn), transformRect(layer, part, x0, y0, x1, y1, fn) | Rewrites texels through a TexelFunction (x, y, argb) -> newArgb without copying the canvas. A canvas is only allocated when a result holds paint. Returns the number of changed texels, or -1 when a new canvas was needed and the budget is full. |
pixel, canvas, hasCanvas, part, allocatedPixels | Read the batch in progress, including changes made earlier in the same callback. |
clearLayer, clearPart, clearAll | Clear keeps the layer; clearAll removes every layer and part. |
establishPart(surfaceId, partName, texW, texH, minU, minV, maxU, maxV) | Makes a part paintable without a player click. Returns the part key. |
canAllocate(layer, part) | Whether a new canvas for that part fits the texel budget. |
A soft round brush with transformRect, fading from full strength at the center:
EntityPaintingAPI.edit(mob, editor -> {
String layer = editor.getOrCreateLayer("mymod:mud", true).orElseThrow();
var part = editor.part(key).orElseThrow();
int cx = part.texelX(textureU), cy = part.texelY(textureV), r = 3;
editor.transformRect(layer, key, cx - r, cy - r, cx + r + 1, cy + r + 1, (x, y, old) -> {
double d = Math.hypot(x - cx, y - cy);
if (d > r) return old;
int alpha = (int) (0xC0 * (1 - d / (r + 1)));
return PixelMode.BLEND.apply(old, (alpha << 24) | 0x664422);
});
});
Pixel methods return false only when the layer needs a new canvas for the part and the entity's texel budget is full. Unknown layer IDs or part keys and out-of-range values throw IllegalArgumentException. API edits skip player rules (held tool, reach, rate limit, maxLayers, locks) but never the hard limits.
One-liners:
EntityPaintingAPI.fillAllParts(mob, "mymod:frost", 0x4080C0FF, PixelMode.BLEND);
EntityPaintingAPI.copyPaint(oldZombieVillager, newVillager); // conversions and clones
EntityPaintingAPI.clearPaint(mob);
Keep edits occasional. Every commit is a new revision. A player whose click was captured against the previous revision has that stroke rejected as stale and must click again. Per-tick effects belong in your renderer, not in paint data.
Painting your own entity from the server
If you know your model's class, texture and part layout, establish parts yourself. The surface ID must match what the client capture builds: the model's fully qualified class name and the texture it actually binds. Pass the class name as a string so server code never loads client classes.
String surface = EntityPaintingAPI.surfaceId("com.example.client.GolemModel",
ResourceLocation.fromNamespaceAndPath("mymod", "textures/entity/golem.png"));
EntityPaintingAPI.edit(golem, editor -> {
// "head" is a 8x8x8 cube at texOffs(0, 0) on a 64x64 texture: its faces span u 0..32, v 0..16.
var head = editor.establishPart(surface, "head", 64, 64, 0f, 0f, 32 / 64f, 16 / 64f);
var layer = editor.getOrCreateLayer("mymod:runes", true);
if (head.isPresent() && layer.isPresent())
editor.stamp(layer.get(), head.get(), 0.5f, 0.5f, 0xFF40FFE0, 1, PixelMode.REPLACE);
});
Part names follow the capture's naming rules (at most EntityPaintingAPI.MAX_PART_NAME, 200 characters):
- A
ModelPartfield uses its field name:head,leftLeg. - Parts in an array, list or map field use
field[index]orfield[key]. - Parts with no Java field use their path from the root:
body/jacket. On NeoForge 1.21.1, a model that is not aHierarchicalModel(a horse, a fox) names such a part by its path below its nearest named part instead:headParts/head/upper_mouth(0.9.0). - A part with more than one cube gets one canvas per cube:
arms#0,arms#1. - Players use
EntityPaintingAPI.playerSurfaceId(modelClassName, "default"|"slim"). - A GeckoLib model (0.9.1) has no model class of its own: its surface uses
geckolib:plus its geo model file in place of the class name, for exampleEntityPaintingAPI.surfaceId("geckolib:mymod:geo/entity/bear.geo.json", texture)with GeckoLib 4 (Minecraft 1.21.1) orgeckolib:mymod:entity/bearwith GeckoLib 5 (1.21.11), whatever yourGeoModel.getModelResourcereturns. Parts are bone names (head,left_leg), and a bone with several cubes getsbone#0,bone#1. A render layer that redraws the same baked model with another texture and a vanilla entity render type is a surface of its own. - A texture a mob only shows in some state is painted as its canonical texture (0.9.0): wolves as their tame texture (wild and angry textures, every variant the client knows), sleeping foxes as awake ones, angry or nectar bees as
bee.png, cold striders as warm ones.surfaceIdapplies this for you. If your mob swaps textures with the same layout, register them withSurfaceAliases.alias(texture, canonical)on both sides, before the first paint;SurfaceAliases.canonical(texture)looks one up. - When Entity Model Features draws the entity with a custom (jem) model (0.8.1), the client appends
+emf:<namespace>:<model>#<variant>to the model class name, so a resource pack's model has surfaces of its own. Vanilla parts keep their names; parts the jem adds arehead/EMF_horn(itsid), orhead/emf@<hex>(a geometry fingerprint) when the jem part has noid. A jem part with its own texture is a separate surface with that texture. Take these IDs fromcaptureGeometry,BodyShapesorviewrather than building them.
The rectangle must cover every face of the part, or paint lands in the wrong place. For entities you don't own, let a player paint first and use the parts listed by view or editor.parts().
Who sees and paints a layer
Give a synced layer a com.fyxe.ffentitypainting.api.access.LayerAccess for viewing and one for painting. Players who fail the view access are never sent the layer: its paint doesn't reach their game at all. Players who see it but fail the paint access see it marked view only and cannot select, paint, clear, rename, move or delete it. API edits and /entitypaint commands are never limited.
// Server: an ALL layer everyone paints, and a Red layer only the red team sees and only the captain paints.
EntityPaintingAPI.edit(flagMob, editor -> {
editor.getOrCreateLayer("ALL", false);
String red = editor.getOrCreateLayer("Red", false).orElseThrow();
editor.setViewAccess(red, LayerAccess.team("red"));
editor.setPaintAccess(red, LayerAccess.of("mymod:captain", "red"));
});
Built-in LayerAccess | Who passes |
|---|---|
LayerAccess.EVERYONE | Everybody (the default) |
LayerAccess.NOBODY | Nobody; only API edits and commands |
LayerAccess.team(name) | Players on that scoreboard team |
LayerAccess.sameTeam() | Players on the painted entity's scoreboard team |
LayerAccess.players(uuid...) | Those players (the argument also accepts names, comma separated) |
LayerAccess.tag(tag) | Players with that /tag |
LayerAccess.self() | The painted player itself |
Add your own rule with LayerRules.register(id, rule) during setup. A rule is a LayerRule: test(ServerPlayer player, LivingEntity entity, String argument), plus an optional acceptsArgument that /entitypaint layer access uses to refuse bad input. The ID is saved with every layer that uses it, so keep it stable; a layer naming a rule that isn't registered (its mod was removed) is hidden from and locked for everyone rather than shown to everyone.
// Common setup: only the team's captain paints.
LayerRules.register("mymod:captain", (player, entity, team) -> Captains.of(team).equals(player.getUUID()));
Rules run on the server thread whenever a layer is sent to a player or a player edits it, and about once a second for each player holding a restricted layer, so keep them cheap. When an answer changes (a player joins a team, a captain is replaced), that player gets a fresh copy within a second; call EntityPaintingAPI.refreshAccess(player) or refreshAccess(entity) to update at once. EntityPaintingAPI.canSee(player, entity, layerId) and canPaint(...) ask the same questions from your code.
Layers players make get EVERYONE by default. To hand them to a team, set the access in the event that creates them: PlayerLayerEdit with action ADD, or a PlayerStamp whose getLayerId() is empty (a player's first stroke when there is no layer it may paint).
PaintEvents.register(PaintEvent.PlayerLayerEdit.class, e -> {
var team = e.getPlayer().getTeam();
if (e.getAction() == PaintEvent.LayerAction.ADD && team != null) {
e.setNewLayerView(LayerAccess.team(team.getName()));
e.setNewLayerPaint(LayerAccess.team(team.getName()));
}
});
What a player who can't see a layer still learns: that the entity's paint changed (its revision moves on for everyone, so updates stay in order), and which parts are painted, not their texels. On the client, PaintView.Layer.canPaint() is this player's answer and viewAccess()/paintAccess() read EVERYONE, because rules never leave the server. Layers with an access rule don't count toward the server's maxLayers.
Reading paint
EntityPaintingAPI.view(mob).ifPresent(view -> {
for (var layer : view.layers()) { // bottom to top
log(layer.name() + " " + layer.opacity() + (layer.locked() ? " locked" : ""));
}
for (var part : view.parts()) {
int argb = view.composite(part.key(), 0, 0); // all visible layers, no base texture
}
});
PaintView is an immutable snapshot: revision(), layers(), parts() (key, surface hash, part name, UV rectangle, canvas size), pixel(layer, part, x, y), composite(part, x, y), compositeCanvas(part) (every texel at once), canvas(layer, part) and partsOnSurface(surfaceId). Each PaintView.Part converts between texture UV and canvas texels with contains(u, v), texelX(u), texelY(v), textureU(x) and textureV(y). hasPaint and revision are cheap checks that do not copy anything; EntityPaintingAPI.surfaceHash(surfaceId) gives the prefix a surface's part keys share.
Events (server only)
Events are delivered to every listener added with PaintEvents.register(Class, Consumer), on any loader. On NeoForge they are also posted on NeoForge.EVENT_BUS, so @SubscribeEvent methods work as before. On Fabric, use PaintEvents.register.
| Event | Cancelable | When |
|---|---|---|
PaintEvent.PlayerStamp | Yes | A player stroke passed every built-in check (including layer access) and is about to apply. Color, mode and radius can be changed, and the access of the layer it creates when getLayerId() is empty. |
PaintEvent.PlayerLayerEdit | Yes | A layer-manager action (ADD, BLEND_MODE, VISIBILITY, CLEAR, OPACITY, REMOVE, MOVE, RENAME) is about to apply. getValue() is the blend mode, opacity percent, up/down or new name. For ADD, setNewLayerView/setNewLayerPaint set the new layer's access. |
PaintEvent.Changed | No | Any committed change, after clients were sent it (or it was queued). getCause() is PLAYER_STAMP, PLAYER_LAYER_EDIT or API; getPlayer() is null for API edits. An edit you make here follows in order as its own revision. |
@SubscribeEvent
public void onStamp(PaintEvent.PlayerStamp e) {
if (!Claims.canBuild(e.getPlayer(), e.getEntity().blockPosition())) {
e.setCanceled(true);
e.getPlayer().displayClientMessage(Component.literal("You can't paint here."), true);
}
}
@SubscribeEvent
public void onChanged(PaintEvent.Changed e) {
if (e.getCause() == PaintEvent.Cause.PLAYER_STAMP) Quests.progress(e.getPlayer(), "paint_a_mob");
}
// Any loader (the only way on Fabric):
PaintEvents.register(PaintEvent.PlayerStamp.class, e -> {
if (!Claims.canBuild(e.getPlayer(), e.getEntity().blockPosition())) e.setCanceled(true);
});
Canceling sends no message by itself, so tell the player why if it helps them. Strokes on a player's local layers never reach the server and fire no event.
On the client
api.client.PaintUpdatedEvent fires on the client main thread (on the NeoForge bus too, and to PaintUpdatedEvent.register(Consumer) listeners on every loader) whenever this client's copy of an entity's paint changes. isSnapshot() is true when the whole paint was replaced (layers, parts or settings may differ) and false when only some texels changed. Use it to refresh anything you derive from paint instead of polling revisions.
@SubscribeEvent
public void onPaint(PaintUpdatedEvent e) {
GlowMasks.invalidate(e.getEntity()); // rebuilt from EntityPaintingAPI.view on next render
}
// Any loader:
PaintUpdatedEvent.register(e -> GlowMasks.invalidate(e.getEntity()));
Part names and surface IDs differ by loader (Fabric names parts by child path and its surface IDs carry the runtime class names), so take them from BodyShapes and PaintView instead of building them yourself.
Opting mobs out
Every living entity can be painted, vanilla or modded, with no registration. To keep your own mobs out (a boss, a mob whose look painting would spoil), list them in the ffentitypainting:unpaintable entity type tag. Ship the file in your mod jar; it needs no dependency on this mod, and datapacks can add to it the same way (see the Datapacks page):
{
"replace": false,
"values": [
{ "id": "mymod:ancient_guardian", "required": false }
]
}
Save it as data/ffentitypainting/tags/entity_type/unpaintable.json in your mod's resources. "required": false lets the same file list mobs from mods that may not be installed.
For a decision the tag can't make, register a rule in common setup so the server and every client agree. It runs for each draw of a painted or clicked entity, so keep it cheap; a rule that throws counts as not matching.
EntityPaintingAPI.excludeEntities(entity -> entity instanceof AncientGuardian guardian && guardian.isEnraged());
boolean ok = EntityPaintingAPI.isPaintable(entity); // the tag and every rule
An excluded mob shows no paint and reports no body shapes, and players can't paint it or open its layer manager ("This mob can't be painted.", checked on the client and the server). Edits made through EntityPaintingAPI.edit are still stored, so lifting the exclusion brings them back.
Client API
com.fyxe.ffentitypainting.api.client.EntityPaintingClientAPI, client only.
// During client setup, only if a model of yours captures the wrong spot: leave it alone.
EntityPaintingClientAPI.excludeModel(GolemModel.class);
EntityPaintingClientAPI.excludeModels(model -> model.getClass().getName().startsWith("com.example.client.weird."));
// From a keybind: paint what the crosshair is on, as a right-click would.
EntityPaintingClientAPI.requestPaint(target, InteractionHand.MAIN_HAND);
EntityPaintingClientAPI.setBrushOpacity(128); // 1 to 255, this client only
String layer = EntityPaintingClientAPI.selectedLayer(target);
Every model is paintable by default (0.9.1). Painting works on models that draw like vanilla ones (ModelPart geometry sent through a LivingEntityRenderer's main body draw or RenderLayer.renderColoredCutoutModel) and on GeckoLib models, using a vanilla entity render type (solid, cutout, cutout no-cull, translucent) with a single texture. Anything else fails closed: custom shaders, emissive passes and geometry drawn outside ModelParts or GeckoLib bones are left as they are, and a click on them is refused. excludeModel and excludeModels are for a model that does capture but lands paint in the wrong spot; the rule sees the vanilla EntityModel or the GeckoLib GeoModel. registerPaintableModel and registerPaintableModels are deprecated and do nothing since 0.9.1. requestPaint needs first-person view and a PaintTool in that hand, and the server still checks reach, permission and revision (unless the selected layer is local).
Local layers
A local layer exists only in one player's game: never sent to the server, drawn over every synced layer, and saved on that player's computer under the world it was painted in. Players make them with Add Local in the layer manager; mods edit them with EntityPaintingClientAPI.editLocal(entity, editor -> ...), which hands you the same PaintEditor as a server batch (isLocal() is true, and access setters throw). Parts the server already knows are listed in the editor so you can paint over them. Local paint comes back when the player returns (unless they turned the saveLocalLayers client setting off) and is deleted when the entity dies or has gone unseen for the player's forgetAfterDays (30 by default). The server's only part is a random world ID it sends on login, so saves follow the world, not the server address. localView(entity) reads it back, clearLocalPaint(entity) removes it, and isLocalLayer(entity, id) tells the two kinds apart.
// Client: a paint particle splashes where it lands, on a layer only this player sees.
@Override public void tick() {
Vec3 from = new Vec3(xo, yo, zo);
super.tick();
Vec3 to = new Vec3(x, y, z);
for (LivingEntity mob : level.getEntitiesOfClass(LivingEntity.class, new AABB(from, to).inflate(1)))
if (EntityPaintingClientAPI.paintLocal(mob, from, to, "mymod:splash", 0xC0FF40A0, 1, PixelMode.BLEND)) { remove(); return; }
}
paintLocal(entity, from, to, layerName, argb, radius, mode) captures the model once, finds where the segment first meets it, creates the named local layer if needed and stamps there; it returns false on a miss. For more control, captureGeometry(...).raycast(from, to) returns a CapturedGeometry.Hit (surface, part, texture UV, world position, distance) that you can pass to establishPart and stampTexture yourself. Each capture is one extra model draw, so call it when something lands, not every tick for every particle.
Where is each spot on the body?
Paint lives in texture space, and only a client knows what a model looks like, so on its own the server cannot tell which texel is a foot and which is an ear. Since 0.7.0 FFEntityPainting can measure that for you: call BodyShapes.request() once (mod constructor or common setup) and every client that joins the server measures nearby standing or crouching entities now and then and reports their shape. On a server where no mod asks, clients never measure.
com.fyxe.ffentitypainting.api.shape.BodyShapes (server thread): surfaces(entity) lists the surface IDs the entity was last measured with, in draw order (empty until a player has been near it); shape(surfaceId) and shapes(entity) return the SurfaceShapes reported so far. A SurfaceShape has the surface ID and texture size and, per part, a grid over the part's UV rectangle (shape.part(name) finds one, or BodyShapes.part(entity, partKey) straight from a painting part key). Each cell holds its height as a fraction of the entity's height (height(gx, gy), 0 at the feet, 1 at the top, -1 where no face covers it), its sideways position in the body frame in entity heights (x(i), z(i)) and its outward facing (normal(i), all zero when unknown). heightAtUv(u, v) and cellAtUv(u, v) look a texture UV up, and canvasCells(paintPart) maps every texel of a paint canvas to the covered cell under it (-1 where none), so height(cell), x(cell) and normal(cell) describe each texel you paint. Body-frame coordinates turn with the entity's body: convert a world offset with BodyFrame.toLocal(dx, dz, entity.yBodyRot). The IDs, names, texture sizes and rectangles are exactly what PaintEditor.establishPart expects.
// Mod constructor: ask clients to measure shapes.
BodyShapes.request();
// Server: mud up to a third of the way up the body.
EntityPaintingAPI.edit(mob, editor -> {
String layer = editor.getOrCreateLayer("mymod:mud", true).orElseThrow();
for (SurfaceShape shape : BodyShapes.shapes(mob))
for (SurfaceShape.Part part : shape.parts()) {
var key = editor.establishPart(shape.surfaceId(), part.name(), shape.textureWidth(), shape.textureHeight(),
part.minU(), part.minV(), part.maxU(), part.maxV());
if (key.isEmpty()) continue; // part budget full
var canvas = editor.part(key.get()).orElseThrow();
int[] cells = part.canvasCells(canvas); // cache this per part and canvas size
editor.transform(layer, key.get(), (x, y, argb) -> {
int cell = cells[y * canvas.width() + x];
return cell >= 0 && part.height(cell) < 0.33f ? 0x80664422 : argb;
});
}
});
The first report of each surface is kept until the server stops and shared by every entity drawn with that model and texture. A modified client can therefore misplace paint for surfaces it reports first, so use shapes for where paint goes, never for rules that must not be cheated. Players can turn measuring off or change its range in the client config, and servers limit how many shapes are kept (shapes.maxCachedShapes) and how far away a report may come from. FFEntityDirt is built this way.
Measuring on the client yourself
For client-side effects, EntityPaintingClientAPI.captureGeometry(entity, partialTick) draws the entity once off-screen (nothing reaches the screen, no click is disturbed, and it works on entities out of view and on your own player in first person) and returns a CapturedGeometry: each surface's surfaceId and texture size, and each part's name, UV rectangle and quads. Positions are in blocks relative to the entity's feet on world axes, so y is height. Part.rasterize(width, height) samples a canvas-shaped grid and returns x, y, z per texel, or NaN where no face covers it. Each part also carries normals(), the outward normal the model submitted for each quad (3 floats per quad, zero when none), and rasterizeNormals(width, height) returns the covering face's normal per texel. geometry.surface(id) and surface.part(name) look one up. EntityPaintingClientAPI.measureShapes(geometry, entity.getBbHeight(), entity.yBodyRot, 32) turns a capture into the same SurfaceShapes clients report.
// Client: find how high each texel of each part sits.
EntityPaintingClientAPI.captureGeometry(mob, 1f).ifPresent(geometry -> {
for (var surface : geometry.surfaces())
for (var part : surface.parts()) {
int w = Math.round((part.maxU() - part.minU()) * surface.textureWidth());
int h = Math.round((part.maxV() - part.minV()) * surface.textureHeight());
float[] xyz = part.rasterize(w, h); // NaN = uncovered
float feetToTexel = xyz[(0 * w + 0) * 3 + 1]; // height of texel (0, 0), in blocks
}
});
captureGeometry costs one extra model draw, so cache results and re-measure only occasionally. It returns empty for invisible entities, unreadable textures, or before the first frame has rendered. Since 0.8.1 it includes EMF custom models, with one surface per texture. PaintEditor.part(key), pixel and canvas read the batch in progress, including changes made earlier in the same callback.
Compatibility notes
- 0.9.4 changes the network protocol (blend modes are sent by index), so clients and servers must both run 0.9.4; 0.9.0 through 0.9.3 still play together. Its API changes are additions: 17 new
BlendModeconstants appended afterOVERLAY, 14 newPixelModeconstants appended afterERASE,BlendMode.previous(),BlendMode.affectsAlpha()andPixelMode.existingOnly(). Existing constants, ordinals and saves are unchanged, but saves store modes by name, so an older version cannot read a layer that uses a new mode, and code that switches over the enums must handle the new constants. Use0.9.4as the lower bound if you use a new mode. - 0.9.2 changes no API, protocol or save format; it adds 3D Skin Layers support.
- 0.9.1 keeps the 0.9.0 network protocol and save format. Its API changes are additions:
EntityPaintingAPI.UNPAINTABLE,excludeEntities,isPaintable,EntityPaintingClientAPI.excludeModelandexcludeModels. Modded models are now paintable without registering, soregisterPaintableModel(s)do nothing. GeckoLib mobs are painted (GeckoLib 4.9 on 1.21.1, 5.4 on 1.21.11), andcaptureGeometryandBodyShapesinclude them. A quad whose UVs leave the texture no longer makes a whole draw unpaintable; only that quad is skipped. - 0.9.0 changed the network protocol (per-player layer snapshots), so clients and servers must both run 0.9.0. Its API changes are additions: code built against 0.8.x keeps working, but mods that declare an upper bound below 0.9.0 must widen it. Saves carry over both ways; an older version ignores layer access and shows restricted layers to everyone.
- 0.7.0 through 0.8.3 used the same protocol and connect to each other.
- 0.8.0 changed the API, so rebuild against it:
BlendModemoved tocom.fyxe.ffentitypainting.api, andSurfaceShape.part(name)returns anOptional. New in 0.8.0:BlendMode.byName/next,PixelMode.byName,PaintUpdatedEvent,SurfaceShape.Part.canvasCells/height(cell)/covered(cell)andBodyShapes.part. - Views and edit batches are cheap: canvases are shared until written, so a batch copies only the canvases it changes.
- Entities an EMF custom model drives are painted since 0.8.1, on surfaces of their own (see the part naming rules). ETF variants resolve to the currently shown texture, so a variant change is a different surface.
- Player paint resets on death unless the server enables
keepPlayerPaintOnDeath. UsecopyPaintin your own clone handler if your mod needs it kept regardless.