FFEntityPainting

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

Minecraft1.21.1 and 1.21.11
LoaderNeoForge (21.1.x, 21.11.x) and Fabric
Mod IDffentitypainting
API packagescom.fyxe.ffentitypainting.api, .api.access, .api.event, .api.client, .api.shape
Source version0.9.4
MavenOne 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 paintPaintTool on your item (onStamp to wear it out)
Custom brush shapes, fades or gradients from the serverPaintEditor.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 itPaintEditor.setViewAccess / setPaintAccess with LayerAccess, your own rules through LayerRules.register
Give layers players create a team or ownersetNewLayerView / setNewLayerPaint on PlayerStamp / PlayerLayerEdit
Paint only one player sees (particles, previews, hints)EntityPaintingClientAPI.editLocal / paintLocal
Block painting in claims, arenas or for some mobsCancel 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 changesPaintUpdatedEvent
Keep paint through a mob conversion or cloneEntityPaintingAPI.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 paintedThe ffentitypainting:unpaintable entity type tag (no code), or EntityPaintingAPI.excludeEntities (see Opting mobs out)
Paint from a keybind or menuEntityPaintingClientAPI.requestPaint

Core concepts


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.

BlendModeResult
NORMAL, MULTIPLY, ADDITIVE, SCREEN, OVERLAYThe original five
DARKEN, LIGHTENPer-channel minimum or maximum
COLOR_DODGE, COLOR_BURN, LINEAR_BURNBrighten or darken by the layer's color, with more punch than screen and multiply
HARD_LIGHT, SOFT_LIGHTThe layer acts as a light: strong (hard) or diffuse (soft)
DIFFERENCE, EXCLUSION, SUBTRACT, DIVIDEArithmetic on the channels, clamped to 0 to 255
HUE, SATURATION, COLOR, LUMINOSITYTake one of hue, saturation, both, or brightness from the layer and the rest from what is beneath. COLOR recolors without flattening shading
ERASERemoves coverage beneath wherever the layer has paint; the layer's own color is ignored
MASKKeeps 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.

PixelModeResult
BLEND, REPLACE, ERASEThe original three
ATOPRecolors only texels that already hold paint, mixing by the color's alpha and keeping each texel's own alpha
BEHINDPaints only behind what is there; existing paint stays on top
MULTIPLY, SCREEN, ADD, SUBTRACT, DARKEN, LIGHTEN, DIFFERENCEThe 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, BURNLighten toward white or darken toward black by the color's alpha; RGB is ignored
INVERTInverts existing paint by the color's alpha; RGB is ignored
SATURATE, DESATURATEPush 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:

MethodNotes
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), renameIndex 0 is the bottom. Removing a layer forgets parts no other layer paints.
setOpacity(id, 0..1), setVisible, setBlendMode, setLockedReturn 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, writeCanvasTexel 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, allocatedPixelsRead the batch in progress, including changes made earlier in the same callback.
clearLayer, clearPart, clearAllClear 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):

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 LayerAccessWho passes
LayerAccess.EVERYONEEverybody (the default)
LayerAccess.NOBODYNobody; 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.

EventCancelableWhen
PaintEvent.PlayerStampYesA 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.PlayerLayerEditYesA 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.ChangedNoAny 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