Coat engine / scaling the sheet
Texture resolution
The coat sheet is HorseSkinGeometry.SHEET_SIZE square — today
2× the vanilla horse sheet. This page is about what it would
take to make that a number instead of a constant: what already scales for free, what
is written in texels and would break, what it costs in memory and frame time at each
step up, and the feature the whole exercise is really for —
painting on whatever white horse the player’s own
resource pack ships, at whatever resolution that pack drew it.
The engine runs at one resolution and only one. Everything below is analysis of the code as it stands plus a design sketch; no scaling work has been started, and none of the cost figures have been measured — they are arithmetic off the array shapes, not a profile. See the last section.
What 128 actually is
There is exactly one number, and it is already expressed as a multiple of vanilla:
| Constant | Where | What it means |
|---|---|---|
TEXELS_PER_UNIT | HorseSkinGeometry |
How many texels one model unit (1/16 block) covers. Call it k. |
SHEET_SIZE | HorseSkinGeometry |
The sheet edge, and it is 64 × k — vanilla’s sheet
scaled by k. |
HD | HdHorseModel, HdBabyHorseModel |
The per-cube texScale, 1 / k. The layer bakes at
TEX_SIZE and each cube divides by TEX_SIZE × HD
= 64, so every normalized UV comes out identical to vanilla. |
That last row is the reason this is tractable at all. The models keep vanilla’s
own 64-space texOffs and box dimensions verbatim and let
texScale do the conversion, and
HorseSkinGeometry.faceMapsOf does the mirror image of the same trick on
the CPU side — it multiplies the same 64-space texOffs by
TEXELS_PER_UNIT to get its UV rectangles. The UV layout is
already a function of k. Nobody has ever typed a texel coordinate into the
geometry tables.
The one place k is not a clean multiplier is the adult layout’s
un-mirroring. Vanilla points all four legs at one patch and both ears at one patch;
HdHorseModel gives each its own patch in what was empty space on the
vanilla sheet, so the coat engine can paint the near and off fore independently.
Those four new texOffs are still 64-space numbers, so they scale with k
like everything else — but they mean the adult template is not simply
“the vanilla sheet, bigger”. The foal is: vanilla’s
BabyHorseModel already gives each leg a distinct patch, so
HdBabyHorseModel is a straight resolution pass with no re-layout.
What already scales for free
More of the engine than you would expect, because of a decision made early and held:
a pattern is a function of a point in body space, not of a texel.
CoatPattern takes (x, y, z) in model units;
HorseSkinGeometry.forEachTexel hands a gene the body point each texel
covers and the gene reads that; BodyNoise and
PatchNoise are sampled in 3D at body-unit frequencies. See
body space.
So raising k is supersampling the same continuous function. Feature sizes are in model units and do not move. A dun’s dorsal stripe is the same physical width; a leopard’s spots are the same physical spots; a smoothstep tuned over 0.4 units stays a 0.4-unit ramp. What changes is that the edges stop being staircases. That is the good half of the answer, and it holds for the great majority of the paint code.
CoatBuildContext.size() exists and is the sheet edge for the bake in
progress. Genes that read that rather than
HorseSkinGeometry.SHEET_SIZE are already resolution-agnostic. It is
currently initialised from the constant, so nothing has ever exercised the
difference — but the parameter is there and the right refactor is to make
it mean something rather than to invent a new one.
What is written in texels, and would break
Everything in this table is an absolute reference — a length, a rectangle or an allocation stated in texels rather than in model units. This is the complete list as of the current source; it is short, which is the encouraging part, but two entries on it are load-bearing.
| Site | What it hard-codes | Effect of changing k |
|---|---|---|
CoatRegions.EYE_RECTS_ADULT /EYE_RECTS_BABY |
Literal {x, y, w, h} texel rectangles for the two eyes on each mesh. |
Wrong immediately. Every value is exactly divisible by k, so they are
really 64-space rectangles that were pre-multiplied — the fix is to store
them in 64-space and multiply by TEXELS_PER_UNIT, the same as the
geometry tables already do. |
EyePatch — the 4-bit quadrant mask |
“An iris is a 2×2 block, so a sector is four bits.” Both heterochromias are built out of it. | Survives k > 2 as a coarse quarter-split (each quadrant becomes a k/2-square block) and dies at k = 1, where the iris is a single texel. Above k = 2 the four-bit premise stops being a rendering fact and starts being a limitation — a real wedge angle is the right replacement. |
SpecPainter.spread |
A texel-space neighbourhood dilation. The radius is a body-unit number, converted
with ceil(radius × TEXELS_PER_UNIT) + 1, then it walks a square
of that many texels per texel. |
Correct at any k and quartic in cost: k² more texels, each searching a k²-times-larger box. This is the single worst thing in the engine for scaling and the one that has to be redesigned rather than re-parameterised. |
HorseSkinGeometry’s sample grid |
A cached Sample[SHEET_SIZE²] per mesh — an object per
mapped texel, each holding a BodyPoint of three doubles. |
Correct at any k and the largest single allocation in the mod. It is an object graph, so it costs roughly an order of magnitude more per texel than the finished texture does, and it is static and permanent. |
GeneticCoatTextureFactory.loadArgb |
Asserts the template PNG is exactly N × N. |
Trivial — but it is also the assertion that would need to become a discovery for the resource-pack feature. |
CoatTextureComposer.bake |
Rejects a template that is not SHEET_SIZE² pixels. |
Trivial; becomes “the template decides n”. |
GeneIconTool.BLOCK |
SCALE / TEXELS_PER_UNIT + 2 — how many icon pixels one coat
texel paints. |
Goes to the + 2 floor at k > 4 and the
gene icons become a sparse dot spray. SCALE has to move with k. |
wiki/gene-creator/js/geometry.js,js/ui.js |
var SHEET_SIZE = 128, and bake.coverage[py * 128 + px]. |
The legacy hand-written JS port (hard rule 3) has its own copy of the number. It breaks silently — the creator would draw a quarter of the horse and report parity against a stale fixture. |
| Both golden files | A SHA-256 per row over the whole sheet. | Every row of both moves. They will tell you the coats changed; they cannot tell you which genes got worse, which is the thing you would actually need to know. |
Every private static final int N = SHEET_SIZE |
A dozen classes in coat/pattern/, plus the texture factory, plus most
of the gene tests. |
Mechanical, and the bulk of the diff: each has to become a field or a
parameter threaded from CoatBuildContext.size(). |
Which genes use absolute references
Effectively none of them do it directly, and that is worth stating
plainly because the intuition runs the other way. Every gene under
genetics/genes/ takes (px, py) from
forEachTexel purely as an address to write to, and does all of
its actual arithmetic on the BodyPoint that comes with it. Grep the
package for a gene that thresholds on px, hashes (px, py)
into a per-texel random, or steps to a neighbouring texel, and there is nothing.
The genes that are nonetheless coupled to k, and how:
- Anything with an
effectsspread mask — viaSpecPainter.spread, which is the shared implementation. Correct at any k, ruinously expensive at a large one. Every data-driven gene that grows a mark outward from pigment goes through here. - Everything that touches an eye — the two heterochromia
genes, the light locus, anything implementing
EyeColorContributionorEyePatchContribution— via the literal eye rectangles and the four-bit sector. Note thatCoatOverlayitself is already resolution-general here: it finds the dark texels inside the rectangle, takes their bounding box and halves it, rather than assuming where the pupil is. Only the rectangles and the four-bit premise are fixed. - Genes tuned by eye at k = 2. This is the soft one and the one that will actually cost time. A softness constant chosen because it “landed on about a texel” becomes a hairline at k = 32. A high-frequency threshold that currently reads as a pleasant dither because the texels are large resolves into visible speckle. Nothing detects this; the goldens move for every gene at once, so they cannot isolate it.
Scale by scale
Sizes below are the whole sheet. Working set is one bake’s live
intermediates — the finished ARGB, two PigmentFields (eight bytes a
texel: two float channels), one ColorField (eighteen: four signed int
channels plus two flag planes), the overlay and the emissive mask — and excludes
the shared sample grid, which is larger still.
| k | Sheet | Texture | Working set / bake | Verdict | |
|---|---|---|---|---|---|
| 0.5× | 1 | 64² | 16 KiB | ~0.2 MiB | Representable, not worth having — see below |
| 1× (today) | 2 | 128² | 64 KiB | ~0.7 MiB | Ships |
| 8× | 16 | 1024² | 4 MiB | ~45 MiB | Plausible target; needs the spread rewrite and off-thread baking |
| 16× | 32 | 2048² | 16 MiB | ~180 MiB | Needs a bounded texture cache and a disk cache |
| 32× | 64 | 4096² | 64 MiB | ~720 MiB | One bake exceeds a default heap |
| 64× | 128 | 8192² | 256 MiB | ~2.9 GiB | Not a resolution, a different program |
0.5× — 64px, k = 1
This is exactly vanilla’s sheet, and it fits: the four extra leg patches and the
extra ear patch that HdHorseModel introduced were placed in space that is
empty on the vanilla 64 layout, so the un-mirrored layout is representable at k = 1
with no overlap. Everything else about it is a downgrade, and specifically:
- The eyes collapse. The iris becomes one texel. Both heterochromias stop existing — not degrade, stop — and iris tinting becomes a single-pixel operation.
- Face markings become unusable. A star, stripe and snip are drawn across a head face that is six units wide; at k = 1 that is six texels for the whole forehead.
- Sock and band edges quantise hard. A leg face is 4×11 texels, so every boundary snaps to a whole texel and the per-leg jitter that makes four socks differ mostly disappears.
It is a useful thing to be able to do — it is what a “potato” setting would be, and it is the resolution a vanilla-faithful resource pack would hand us — but it is not a mode to design for.
8× — 1024px, k = 16
The first genuinely interesting step, and the one to aim at if this is ever built. A texel is 1/16 of a model unit, so the sheet finally has more resolution than the geometry has structure: dapples, ticking and roan hairs become individually drawable rather than implied, and the template’s hair shading could carry real strand detail. Four mebibytes a horse is a real but survivable texture.
What has to change first: spread (a 4-unit radius means searching a
65×65 box per texel over a million texels — four orders of magnitude more
sample work than today), and the bake has to leave the render thread. Today
getOrCreate composes synchronously inside the cache’s own lock
on the render thread, the first frame a horse of a new genome is visible. At
k = 2 that is a stutter; at k = 16 it is a multi-second freeze
every time a new horse walks over a hill.
16× — 2048px, k = 32
Past the point where a texel corresponds to anything anatomical, and into territory
where the constraint is the cache rather than the bake. This is the step that made the
unbounded cache worth fixing before anything else: it was keyed by genome and never
evicted within a session, so twenty distinct horses at this size would have been
320 MiB of VRAM held until logout. Both caches are now
TexelBudgetCaches, and the budget is in texels precisely so that
this row of the table costs the same memory as every other one — what changes
with k is how many coats fit inside it, which at 2048px is a couple of
dozen. That is the point where the cache starts evicting a horse that is still on
screen, and where the answer stops being a bigger budget and becomes an off-thread
bake plus a smaller sheet for distant horses.
32× and 64× — 4096px and 8192px
Both are legal OpenGL — GL_MAX_TEXTURE_SIZE is 16384 or better on
anything modern — and neither is legal arithmetic. A single
ColorField at k = 128 is six arrays over 67 million texels,
about 1.2 GiB, and the composer holds one of those plus two pigment fields at once. A
horse would cost more VRAM than most players have, for detail no player can see: at
8192² a texel is 1/128 of a model unit, roughly a hundredth of a Minecraft block,
on a mob usually viewed from four blocks away.
If ultra resolution is ever wanted, the answer is not a bigger sheet. It is to stop baking a sheet — move the pigment and colour fields into a fragment shader and evaluate the same body-space functions per fragment, which is the one approach whose cost tracks screen pixels instead of texels. That is a different project and it would have to live in the NeoForge module, so it buys nothing for the backport.
Re-tooling the drawers
In dependency order, because two of these gate the rest:
- Make
SHEET_SIZEa parameter, not a constant.HorseSkinGeometrybecomes per-k: the geometry tables already are, but the baked mesh and its sample grid have to be keyed by(Skin, k)rather than bySkinalone. Everystatic final int Nincoat/pattern/becomes a field read fromCoatBuildContext.size(). This is a large, boring diff and it is almost the whole job. - Redesign
spread. A per-texel square search is the wrong shape at any large k. Three candidates, none tried: run the mask at a fixed low resolution and bilinearly upsample the coverage (cheap, correct-looking, loses crisp edges); replace the search with a separable two-pass dilation over the body-space grid (linear in radius rather than quadratic); or precompute a signed distance field over the sheet once per bake and have every spread mask read it (one O(n²) pass, then O(1) per texel — probably the right answer, and it would speed up k = 2 as well). - Move the eye rectangles into 64-space and multiply by k, the way
faceMapsOfalready does. Every current value divides exactly, so this is a pure refactor with no behaviour change at k = 2 — which means the goldens can prove it. - Give
EyePatcha real shape. Above k = 2 a four-bit mask is throwing away the resolution you just paid for. A sector angle plus a fraction, rasterised against the iris bounding boxCoatOverlayalready computes, degrades gracefully in both directions: it reproduces the four quadrants at k = 2 and gives a real wedge at k = 16. - Bake off-thread. The composer is pure and allocates nothing
shared, so it is already safe to run on a worker; the only thing that must happen
on the render thread is the
NativeImageupload. Render the white template until the bake lands. - Bound the cache.
LRU with a texel budget rather than an entry count, so one setting covers every k.Done 2026-09-09 —TexelBudgetCache, one budget in texels, plus an entry floor so the cache cannot end up smaller than a frame at a large k. It is the one item on this list that was worth doing before the resolution work rather than during it, because it was already a defect at the current size. - Scale the tools with it.
GeneIconTool.SCALE,CoatSampleTool,CreatorAssetTool,GeneWikiToolandDesignerApiall read the template and assume its size; the wiki tools should keep baking at k = 2 whatever the game runs at, since a gene icon does not want to be a megabyte. - Decide what happens to the legacy creator JS. It has its own
SHEET_SIZEand cannot be extended (hard rule 3). Either it is pinned to k = 2 forever and the creator stops claiming to preview what the game will draw, or it is finally retired in favour of the wasm path.
Memory, frame time and storage
Memory is the sample grid, not the texture
The obvious cost — the finished ARGB sheet — is the smallest one. Per
mapped texel the engine holds four bytes of output, eight of pigment (twice over,
since a gene works on a copy), eighteen of colour accumulator, four of overlay and one
of emissive mask: call it forty-odd bytes of scratch for every four bytes of result.
And above all of that sits HorseSkinGeometry’s sample grid, which is
a Sample object plus a BodyPoint object per mapped texel
— on the order of seventy bytes each, allocated once per mesh and never freed.
At k = 2 it is about a megabyte and nobody notices. At k = 16 it is
tens of megabytes of permanently resident object graph, and it is the first thing that
should be flattened into parallel primitive arrays if any of this is attempted.
Frame time
Most of the bake is linear in texels: Genes.naturalOrder().size() passes
over the sheet in phase 1, Genes.magicalOrder().size() in phase 3, one
gradient lookup per texel in phase 2. Those scale as k² and are unpleasant but
predictable. The spread mask scales as k⁴ and is not. The other quiet cost is
allocation churn: phase 1 hands each visible natural gene a
PigmentView.mutableCopy(), so a bake allocates and discards one whole
pigment field per visible natural gene — a rounding error today, hundreds of
megabytes of garbage per horse at k = 16.
Storage is nearly free, and that is deliberate
No coat is ever written to disk. A horse stores its genome and epigenome; the texture is re-derived on the client that needs it and thrown away on logout. So raising the resolution costs essentially nothing in save size, in jar size or in network traffic — the only on-disk growth is the two white templates and the gradient LUTs, and the templates are the one thing the resource-pack plan would stop shipping anyway. This is a real advantage of generating rather than selecting, and it is worth not giving up: if a disk cache is added at high k it should be a cache in the strict sense — deletable at any time, never a save format.
Does any of this need OptiFine?
No. Nothing about the resolution of a generated entity texture
involves OptiFine, or ETF, or EMF, on either version. The coat is a
NativeImage uploaded as a DynamicTexture and bound by an
ordinary entity renderer; the size of that image is a matter between the mod and the
driver. Entity textures are not atlased, so there is no atlas limit to run into, and
the ceiling is GL_MAX_TEXTURE_SIZE and VRAM — both of which sit far
above any resolution worth wanting.
The reason people associate high-resolution horses with OptiFine is that OptiFine (and now ETF / EMF) is how a resource pack gets to supply custom entity models and per-entity texture variants. That is the selection problem, and this mod does not have it: we generate the texture rather than picking one, so the machinery has nothing to do here. The one idea worth taking from that world is the principle — that the pack should own the pixels — and that is the next section.
Two things genuinely would want attention at high k and are not OptiFine’s
business either. Mipmaps: a DynamicTexture uploads one
level, so a 1024² horse seen from thirty blocks away will shimmer where a
mipmapped one would not. Filtering: the coat is nearest-filtered like
every vanilla entity texture, which is right at k = 2 and starts to look
like a mistake once texels are subpixel.
26.1.2 versus the 1.12.2 backport
The module split pays off here: every item in
the re-tooling list except the last two lives in
common/ and is version-neutral. What differs is the translation layer.
| NeoForge 26.1.2 | Forge 1.12.2 (planned) | |
|---|---|---|
| Changing the sheet size on the model | Two constants. LayerDefinition.create(mesh, TEX_SIZE, TEX_SIZE)
with a per-cube texScale of 1/k; vanilla’s
texOffs numbers stay untouched. |
There is no per-cube texScale. ModelBase has
textureWidth/textureHeight and
ModelRenderer.setTextureOffset divides by them, so you set both to
64 × k and every offset scales uniformly — which happens
to be exactly what we want, but it is a different mechanism and the
un-mirrored leg patches have to be re-derived by hand. |
| Uploading the texture | NativeImage + DynamicTexture, registered with
the TextureManager. |
DynamicTexture exists but is an int[] and
TextureUtil upload; workable, and the int[] the composer
already returns is the native format there. Hundreds of live dynamic textures is
less well-trodden ground on that version. |
| Hardware floor | Core-profile GL on hardware from the last decade. | The realistic 1.12.2 audience includes machines with a 4096 or even
2048 GL_MAX_TEXTURE_SIZE, and 32-bit JVMs with 2 GiB heaps. 8×
is the ceiling there, and it needs a runtime cap, not a config the player can
set past their driver. |
The common/ constraint |
Whatever the parameterisation looks like it must stay
inside the rules: no Minecraft imports, no Java 9+
APIs. Plain fields and constructor parameters — no records-with-defaults
cleverness, nothing from java.util.concurrent that postdates 8. |
|
| Custom entity models in the wild | Rare; ETF/EMF users are a minority. | OptiFine is close to universal on 1.12.2, and OptiFine
.jem custom entity models silently replace the horse mesh —
which means our UV assumptions break for a large slice of that audience.
Compatibility covers what a remap would take. |
Planned: paint on the player’s own resource pack
The mod should stop shipping a white horse. It should read whatever white horse the player’s active resource pack supplies, at whatever resolution that pack drew it, and paint the genome on top of that. A player running a 512× pack gets 512× horses whose art is the pack’s own, with our genetics on it; a player running vanilla gets vanilla-looking horses. The mod supplies the pattern, the pack supplies the pixels.
This is not implemented and this section is a design sketch, not a specification. But it is the reason the rest of the page matters: resolution parameterisation is not the feature, it is the prerequisite. The moment the template comes from the pack, k stops being ours to choose.
What makes it plausible
The paths line up exactly. Vanilla 26.1.2 ships
minecraft:textures/entity/horse/horse_white.png and
horse_white_baby.png, both 64² — the same two files, under the
same two names, that this mod keeps its own 128² copies of. And
Minecraft.getInstance().getResourceManager(), which
GeneticCoatTextureFactory already uses, already resolves through
the player’s active pack stack. Reading the pack’s horse instead of ours is
a namespace change on one identifier. Everything below is what has to happen around
that one line.
The five problems, in the order they bite
-
Discovering k. Read the image, require it square, set
k = width / 64and require that to be a positive integer. A 192² pack is k = 3 and fine — nothing in the engine needs k to be a power of two, since the geometry tables multiply rather than shift. A 100² sheet is refused, loudly, and we fall back to a bundled template. The current exact-size assertion inloadArgbbecomes this. -
Un-mirroring the adult sheet. This is the real work. Vanilla’s
adult layout shares one patch across all four legs and one across both ears; our
engine paints each independently, which is what
the whole pipeline assumes. So a runtime pass has to
take the pack’s 64k-sized vanilla sheet and blit the shared leg patch into
the three extra leg slots and the ear patch into the extra ear slot —
exactly the transformation that produced our shipped
horse_white.pngby hand, expressed as a function of k. The foal needs none of this: vanilla’s baby model already gives each leg its own patch, so the foal template is a straight read. -
A pack’s white horse is not our white horse. The composite
onto the template is a multiply, which assumes the template carries
shading and nothing else. Vanilla’s
horse_whiteis near enough. A pack that draws its white horse warm, or cream, or with a painted-on blaze, would tint or mark every coat in the game. Three answers, and this one is the owner’s call: normalise the template against its own peak luminance per region; accept the tint as the pack’s artistic intent, which is arguably the honest reading of “use the pack’s art”; or gate the whole feature behind a config default-off. Flagged, undecided. -
An explicit opt-in path, for packs that want control. Two tiers,
no more: if the pack provides
horsegenetics:textures/entity/horse/horse_white.pngwe use it verbatim, un-mirrored layout and all, and the pack author has total control. If it does not, we derive fromminecraft:and do the un-mirroring ourselves. No third case, no fallback chain. -
Reload and invalidation. Changing packs has to drop every baked
coat and re-derive the templates. Built 2026-09-09, ahead of the
rest of this:
CoatAssetReloadis a client reload listener that callsGeneticCoatTextureFactory.clear(), so F3+T and a pack switch already invalidate every coat. It was the piece most likely to be forgotten and it was also already a defect — without it, editing the gradient and reloading appeared to do nothing at all.
Consequences worth accepting up front
- Two players see different horses. The genome is authoritative and shared; the pixels are local. A screenshot stops being canonical. That is correct behaviour for a texture pack and should be said out loud rather than treated as a bug report later.
- The vanilla markings overlays stay ignored. Packs also retexture
horse_markings_*.png, and the renderer deliberately has no marking layer — all white patterning comes from the genome. A pack whose character lives mostly in its markings will lose that half. - The browser tools diverge. The horse designer and the gene creator bake against the shipped template and cannot see the player’s pack. A deliberate divergence, and one to write in the comment on both sides per hard rule 5.
- The eye rectangles become the pack’s problem. They are stated in 64-space and multiplied by k — which is correct for any pack that keeps vanilla’s layout, and silently wrong for one that moves the eyes. There is no defence against that short of asking the pack to declare it, and it is not worth building one until someone hits it.
Unverified
Every cost figure on this page is arithmetic, not measurement. They
come from array shapes and element sizes in the source; no bake has been profiled, no
resolution other than the shipped one has ever been run, and the k⁴ claim about
spread is read off the loop bounds rather than timed. The list of absolute
references was assembled by grep and by reading coat/pattern/ and
genetics/genes/, and the claim that no gene indexes on texel coordinates
is a negative result from searching — strong, but a negative result.
The two facts here that are checked: vanilla 26.1.2 ships
horse_white.png and horse_white_baby.png at 64² under
the paths quoted, and every current eye-rectangle value divides exactly by
TEXELS_PER_UNIT.
HorseSkinGeometry.TEXELS_PER_UNIT and
SHEET_SIZE; the model side is HdHorseModel.HD /
TEX_SIZE.