Generated primarily by AI (Claude Code).
Language: English | 中文
Procedurally generates animated Minecraft fluid textures (_still.png / _flow.png + .mcmeta) from a noise pipeline — molten metals, water, potions, or anything else fluid-shaped — instead of hand-painting them. Give it a fluid id and one tint color, and it produces a full looping animation (source-block "still" texture + flowing "flow" texture) with optional highlight/spot/warp/scroll/lighting/stylize/translucency effects layered on top.
- Requirements
- Quick start
- GUI (real-time preview)
- How it works
- CLI reference
- Noise types
- Base look: color &
--darken - Effect:
--period(noise frequency) - Effect:
--warp-strength(swirl distortion) - Effect: highlight & spot layers
- Effect: normal-derived lighting
- Effect: Stylize (posterize / dither / outline)
- Effect: translucency (
--alpha) - Testing
- Examples
- Reproducing the 8 bundled fluids
- Known limitations
- License
- Python, managed via uv (
uv run ...handles the virtual environment and dependencies automatically — no manualpip installneeded)
cd fluid_texture_gen
uv run generate.py <fluid_id> <tint_hex>Example:
uv run generate.py molten_titanium C0C0C8This writes into output/:
<fluid_id>_still.png+.mcmeta— the at-rest source-block texture (looping animation, no directional drift)<fluid_id>_flow.png+.mcmeta— the flowing texture (same base look, optionally more turbulent/faster, can drift in a direction via--scroll-x/--scroll-y)<fluid_id>_still_preview_strip.png— every frame of the_stillanimation laid out side by side in one image, so you can eyeball the whole loop without opening it in-game or in an animated image viewer
Copy the _still/_flow PNG + .mcmeta pairs into {your_mod}/src/main/resources/assets/{your_modid}/textures/block/ to actually use them in-game.
For interactive tuning instead of hand-editing CLI flags, there's a desktop GUI: live 3D preview (a couple of Minecraft-style blocks, orbit camera, draggable light-direction rings) and 2D preview that update as you adjust any parameter, presets (the 8 bundled fluids plus the worked examples below), and one-click export.
uv sync --group gui # one-time
uv run python -m gui.appNo Python install needed: grab a prebuilt Windows .exe from Releases instead.
See gui/README.md for the full walkthrough (controls, viewport, presets, architecture).
gen/noise.py— the noise field itself: several algorithms (see Noise types below), all tileable so they repeat seamlessly across blocksgen/color.py— maps a[0, 1]noise field to RGB from one tint color, with brightness/saturation shading (see Base look) and the near-whitehighlight_tint()used by the highlight layersgen/lighting.py— derives a surface normal from the noise field's own gradient and lights it (diffuse reshading + Blinn-Phong specular), see Effect: normal-derived lightinggen/stylize.py— the posterize/dither/outline post-process, see Effect: Stylizegen/animate.py— turns a noise generator + tint into a full animated frame sequence, composites the optional highlight/spot/lighting/stylize/alpha layers, and saves the Minecraft-style vertically-stacked PNG +.mcmetagenerate.py— the CLI wrapping all of the abovetests/— pytest suite covering every module above, see Testing
| Flag | Default | Purpose |
|---|---|---|
fluid_id |
— | Output file stem (e.g. molten_iron) |
tint_hex |
— | Base color, e.g. 9C9C9C or #9C9C9C |
--noise |
value3d |
Noise algorithm — see Noise types below |
--size |
32 | Texture resolution (square) |
--frames |
60 | Number of animation frames in the loop |
--seed |
0 | RNG seed for the _still texture (the _flow texture always uses seed + 1000, so they're related but not identical) |
--darken |
1.0 | Brightness multiplier applied after normalization — < 1.0 darkens, > 1.0 brightens (can exceed 1.0; gets clipped). See Base look |
--period |
4 | Noise frequency — lower = bigger blobs, higher = finer speckle. See Effect: --period |
--cell-count |
10 | --noise worley/voronoi_edges only: number of Worley feature points — lower = bigger cells, higher = finer cracks/cells (these two noise types don't use --period) |
--warp-strength |
0.0 (off) | Domain-warp distortion strength (value3d/ridged3d only) — swirls/streaks instead of a static "marbled" look. In absolute pixels, so it needs to scale with --size (doubling size roughly needs double the warp strength for the same relative effect). See Effect: --warp-strength |
--warp-period |
4 | Spatial frequency of the warp distortion field itself |
--flow-warp-strength |
(same as --warp-strength) |
Override warp strength for the _flow texture only — e.g. a higher value makes the flowing/moving state look more turbulent than the at-rest _still source block |
--scroll-x / --scroll-y |
0.0 (off) | Directional drift, in texture-widths per full loop. Only ever applied to the _flow texture — _still always stays on the plain non-scrolling animation. Use an integer to keep the loop seamless (a non-integer leaves a small jump between the last frame and frame 0) |
--frametime |
3 | Ticks per animation frame (20 ticks/sec) — lower = faster |
--flow-frametime |
(same as --frametime) |
Override frametime for the _flow texture only |
--highlight-strength |
0.0 (off) | Specular-sparkle layer strength — sparse bright dots blended toward an analogous near-white. See Effect: highlight & spot layers |
--highlight-count |
14 | Number of highlight dots |
--highlight-sharpness |
12.0 | Higher = tighter/sharper dots (polished metal); lower = soft/matte |
--highlight-saturation |
0.5 | How much of the tint's own hue survives in the highlight color (shared by --highlight-strength and --normal-highlight-strength) — 0 = plain white, higher = more visibly colored |
--spot-strength |
0.0 (off) | Bubble/light-fleck layer strength — same mechanic as highlight, meant for fewer/bigger/optionally-tinted dots |
--spot-count |
5 | Number of spot dots |
--spot-sharpness |
5.0 | Dot falloff sharpness |
--spot-color |
white | Hex color for the spot layer |
--highlight-gate |
0.0 (off) | Require the base noise to be at least this bright (its own stretched 0-1 range) before a highlight dot shows through, instead of the dots being fully independent of the noise they sit on. See Effect: highlight & spot layers |
--spot-gate |
0.0 (off) | Same as --highlight-gate, for the spot layer |
--normal-light-strength |
0.0 (off) | Blends in gradient-derived lighting (the noise treated as a height map, lit from --normal-light-dir) on top of the existing brightness-ramp shading. See Effect: normal-derived lighting |
--normal-light-dir |
45,60 |
azimuth,elevation in degrees for the light source used by --normal-light-strength and --normal-highlight-strength |
--normal-highlight-strength |
0.0 (off) | Specular highlight (Blinn-Phong off the noise's own surface normal) blended toward the analogous near-white highlight color — unlike --highlight-strength's fixed dot layer, this is recomputed every frame straight from that frame's noise, so it moves/reshapes with however the noise itself animates. See Effect: normal-derived lighting |
--normal-highlight-sharpness |
24.0 | Higher = tighter/sharper specular glints; lower = broad soft sheen |
--normal-gradient-scale |
4.0 | How much the noise's own slope is exaggerated before deriving a surface normal from it (shared by --normal-light-strength and --normal-highlight-strength) — higher reads as a rougher/bumpier surface, lower as flatter/smoother |
--posterize |
0 (off/continuous) | Color levels per channel — e.g. 4 for a hard retro-pixel look. See Effect: Stylize |
--dither |
none |
none/bayer/noise — dither pattern applied before --posterize quantizes, breaks up hard color bands |
--outline |
none |
none/soft/hard — darkens pixels where the base noise changes steeply, like a faceted line-art edge |
--outline-strength |
0.3 | How dark --outline's edges go |
--alpha |
1.0 (fully opaque) | Flat opacity for translucent fluids like water/potions. See Effect: translucency |
--alpha-noise-strength |
0.0 (off) | Lets the base noise's own brightness modulate --alpha per-pixel, so depth/thickness reads as varying transparency instead of one flat opacity. See Effect: translucency |
--out-name |
(same as fluid_id) |
Override the output file stem |
| Value | Look | Notes |
|---|---|---|
value3d |
Smooth, organic, time-coherent | Default. True 3D (x, y, t) fractal value noise — every frame is a slice of one continuous noise volume instead of crossfading between unrelated 2D fields, so motion evolves the same pattern over time rather than morphing between different ones |
ridged3d |
Cracked/glowing veins | value3d folded around its midpoint (1 - abs(2n - 1)) — turns smooth rolling hills into sharp ridges/cracks, while keeping the same full time coherence |
value |
Smooth, organic (2D only) | Classic fractal value noise, animated by crossfading between 4 independently-seeded fields — the original technique, superseded by value3d for animation quality but still available |
worley |
Cellular / cracked crust | Distance-to-nearest-feature-point noise; reads as cooling plates or cell boundaries. Configured via --cell-count, not --period |
ridged |
Sharp veins (2D only) | 2D version of the ridged-fold trick, crossfade-animated |
voronoi_edges |
Thin glowing cracks | Only the boundaries between Worley cells light up. Also configured via --cell-count |
warped |
Churning/swirling | Single-pass domain warp (2D only) — a simpler, non-recursive version of what --warp-strength does for the 3D types |
domain_warp |
Marbled swirls/streaks | Inigo Quilez's classic recursive FBM domain warp (warps the sample position with noise-of-noise, twice) — a static, non-time-coherent alternative marbled look |
gen/color.py takes your one tint color and does all the shading itself — you don't hand-paint light/dark variants. It reads the tint's hue/saturation/brightness, then:
- keeps the tint's hue as the base, with a small analogous hue/saturation drift so brighter spots read a touch warmer/richer instead of pure greyscale shading
- normalizes brightness across fluids so a naturally-dark tint (e.g. netherite) and a naturally-bright one (e.g. diamond) don't look wildly mismatched side by side in-game
--darkenis your per-fluid override on top of that normalization —< 1.0darkens,> 1.0brightens:
Lower = bigger blobs/patches, higher = finer speckle:
0 keeps the noise looking like a static "marbled" pattern; raising it drags the noise through a distortion field so it reads as swirling/churning liquid instead. Only affects value3d/ridged3d. It's in absolute pixels, so it needs to scale with --size — doubling the texture size roughly needs double the warp strength for the same relative effect:
Both are the same underlying mechanic (sparse Worley-noise dots), just tuned differently and blended toward a different color. The dot positions are only ever generated once per texture, never re-randomized frame to frame (see Known limitations for why) — but for value3d/ridged3d, every frame resamples that fixed dot field through the same warp/scroll drift as the color noise, so the dots ride along with the flow instead of hovering in place while everything else visibly moves. This means --scroll-x/--scroll-y and --warp-strength now also carry the highlight/spot layers along with them on the _flow texture (all 8 bundled fluids use both):
--highlight-strength/--highlight-count/--highlight-sharpness— blends toward an analogous near-white (see below); meant for small, tight, sharp dots (specular sparkle off a polished surface)--spot-strength/--spot-count/--spot-sharpness/--spot-color— blends toward--spot-color(white by default); meant for fewer, bigger, softer dots (bubbles or light flecks), and can be tinted (the bundled diamond fluid uses a pale cyan)
--highlight-strength (and --normal-highlight-strength below) don't blend all the way to plain white (1, 1, 1) — gen/color.py's highlight_tint() keeps a bit of the fluid's own hue instead, controlled by --highlight-saturation (default 0.5), since a fully desaturated highlight reads as a foreign white blob dropped on top of a colored material rather than a bright reflection of it. --spot-color already lets you pick the blend color explicitly, so it's untouched by this.
By default the dots' positions are entirely independent of the base noise's own brightness — they can land anywhere, including on already-dark patches, which can read as a "sticker" pasted on top rather than part of the material itself. --highlight-gate/--spot-gate (both off by default, so existing fluids are unaffected unless you opt in) additionally require the base noise at that pixel to be at least the given threshold (in its own stretched 0-1 range) before the dot shows through, via a smoothstep ramp rather than a hard cutoff — a hard noise > threshold test would let ordinary per-frame noise jitter right at the threshold flicker a dot on/off, and would turn into an actual seam at the loop boundary.
--normal-light-strength (0 = off) treats the base noise field as a height map, derives an approximate surface normal from its own gradient (via wrapped finite differences, so it stays seamless across the tile boundary), and lights it from a fixed direction (--normal-light-dir, azimuth,elevation in degrees). --normal-gradient-scale (default 4.0) controls how much the noise's own slope is exaggerated before that normal is derived — higher reads as a rougher/bumpier surface. The result blends on top of the existing brightness-ramp shading — unlike the highlight/spot dot layers, this doesn't add any new bright spots, it reshades the existing noise-driven color so bumps read as lit/shadowed based on which way they actually face, giving the surface more of a "real bump" feel than a value ramp alone.
--normal-highlight-strength (0 = off) uses that same surface normal to add an actual specular glint (Blinn-Phong, N.H raised to --normal-highlight-sharpness), blended toward the same analogous near-white highlight_tint() color as --highlight-strength (see Effect: highlight & spot layers for --highlight-saturation). This exists specifically because --highlight-strength's dot layer is generated once per texture and only rides along with an explicit --warp-strength/--scroll-x/--scroll-y drift — on the non-scrolling _still texture with those off (or even with them on), the dots don't track value3d/ridged3d's own in-place "breathing" shape-morph, so they can visibly hold still while everything else keeps subtly moving. --normal-highlight-strength has no such disconnect: it's recomputed fresh every frame straight from that frame's own noise, so it always moves and reshapes exactly with however the surface itself is animating. This is why all 8 bundled fluids switched from --highlight-strength to --normal-highlight-strength (see Reproducing the 8 bundled fluids) — --highlight-strength is still available and still useful (e.g. for a look with distinctly separate sparkle positions rather than shading tied to the surface itself), just no longer the bundled fluids' default.
--posterize, --dither, and --outline run as a final post-process, after every other layer (color, highlight, spot, normal-light, alpha) is composited — this is what pushes the exact same pipeline toward a "retro MC pixel" look instead of a polished one, rather than being a separate mode/code path:
--posterize <levels>(0 = off/continuous) quantizes each color channel to<levels>discrete steps — e.g.4gives the classic hard-banded retro look--dither none|bayer|noise(only matters together with--posterize) breaks up the hard color bands:bayeris the classic tiled ordered-dither halftone pattern;noiseis a fixed-per-texture random threshold (regenerated once, not per frame, so the dither pattern itself doesn't crawl across the animation)--outline none|soft|hard+--outline-strengthdarkens pixels where the base noise changes steeply, reading as a faceted line-art edge around the noise's own blobs/ridges
All three default to off, so none of the 8 bundled fluids change unless you explicitly opt in. See Known limitations for a caveat about --dither bayer at small texture sizes.
--alpha (1.0 = fully opaque, the default — matches every fluid before this existed) sets a flat opacity for the whole texture. This is what makes water, potions, or any other translucent fluid possible instead of only opaque molten metal: give it a low --alpha and the PNG itself carries real per-pixel transparency.
--alpha-noise-strength (0 = off) additionally lets the base noise's own brightness modulate that opacity per-pixel — brighter/thicker-looking areas of the noise stay closer to --alpha's full value, dimmer areas fade further toward transparent — so depth/thickness reads as varying transparency rather than one uniform opacity across the whole block, which is how an actual body of liquid with varying depth looks.
Important caveat: this only controls what the generated PNG encodes. Whether Minecraft/NeoForge actually renders it as translucent in-game still depends on the fluid having a translucent render type registered on the Java side — a texture generator can't fix that from outside the mod's code. See Known limitations.
uv run pytesttests/ covers every gen/ module (noise generators, color mapping, lighting, stylize, frame compositing/alpha) plus an end-to-end CLI suite that runs generate.py against an isolated temp output directory (so test runs never touch the real output/ folder). Tests are pure/deterministic — every noise/color/lighting function takes an explicit seed or fixed input, so assertions compare exact values or well-defined invariants (output range, shape, determinism given the same seed) rather than eyeballing images.
A few complete commands showing how the flags combine in practice (all runnable as-is). Each image below is _still (left) / _flow (right), frame 0, cropped and upscaled from the actual generated 32x32 PNG, over a checkerboard so translucency is visible where relevant.
Polished/liquid-metal look — directional lighting reshades the existing noise, plus a specular glint that moves with the surface instead of a fixed dot layer. No stylize:
uv run generate.py molten_mercury B8B8C0 \
--period 4 --warp-strength 8 --t-period 6 \
--normal-light-strength 0.7 --normal-highlight-strength 0.3 --normal-highlight-sharpness 30 \
--flow-warp-strength 12 --flow-frametime 2 --scroll-y -1Retro MC pixel look — same pipeline, pushed the other way via the Stylize stage:
uv run generate.py molten_pixel_iron 9C9C9C \
--period 3 --posterize 4 --dither bayer --outline soft --outline-strength 0.35Bubbling/gated look — highlight and spot dots only show up on the base noise's own bright ridges, instead of scattering independently of it:
uv run generate.py molten_bubbling_gold DCB342 \
--darken 1.2 --spot-strength 0.3 --spot-count 5 --spot-gate 0.5 \
--highlight-strength 0.4 --highlight-gate 0.55A cracked/veined look — ridged3d instead of the default value3d, no warp (stays static/marbled rather than swirling):
uv run generate.py molten_cracked_diamond 54BDB4 --noise ridged3d --period 3 --t-period 6 \
--highlight-strength 0.6 --highlight-count 10 --highlight-sharpness 16Water — translucent, gently warped, a soft normal-lit sheen instead of an opaque metal look:
uv run generate.py water_example 3F76E4 \
--period 4 --warp-strength 6 --t-period 6 --scroll-y -1 --flow-warp-strength 9 \
--alpha 0.55 --alpha-noise-strength 0.5 \
--normal-light-strength 0.4 --normal-highlight-strength 0.25Potion — translucent with tinted light flecks, for a magical-brew look:
uv run generate.py potion_example 9B30C9 \
--period 3 --warp-strength 5 --t-period 6 \
--alpha 0.6 --alpha-noise-strength 0.4 \
--spot-strength 0.4 --spot-count 6 --spot-sharpness 5 \
--highlight-strength 0.3 --highlight-count 8See regenerate_all.sh (below) for the exact, currently-shipped command for every bundled fluid — the most reliable reference for what a fully-tuned, production command line looks like.
regenerate_all.sh records the exact parameters currently used for all 8 fluids shipped with the mod (iron, gold, copper, diamond, netherite, zinc, brass, amber gold) — flow speed, noise frequency, highlight/spot layers, per-fluid brightness, and the _flow-only warp/scroll overrides. All 8 use --normal-highlight-strength 0.2 rather than the dot-based --highlight-strength (see Effect: normal-derived lighting for why), plus --spot-strength for a few fluids' bubble/light-fleck layer. Each command line is commented with the reasoning behind that fluid's specific values.
bash regenerate_all.shThen copy everything out of output/ into {your_mod}/src/main/resources/assets/{your_modid}/textures/block/.
- The
--highlight-strength/--spot-strengthdot positions are generated once per texture, not once per frame — this is intentional (regenerating them every frame would make the dots flicker/re-randomize instead of holding still). Forvalue3d/ridged3dthey're resampled every frame through the same warp/scroll drift as the color noise, so they do ride along with--scroll-x/--scroll-y/--warp-strength. What they still don't do is track the noise's own organic time-evolution (the "breathing" shape-morphvalue3d/ridged3dhave even with scroll and warp both off) — that would need dots that follow specific noise features frame to frame, a different technique than a fixed dot field. Other (2D, crossfade-animated) noise types don't expose a warp/scroll field to attach to at all, so their dots stay fully static.--normal-highlight-strengthdoesn't have this problem (see Effect: normal-derived lighting), which is why the 8 bundled fluids use it instead —--spot-strength(bubbles/light-flecks) still uses the fixed-dot approach, since a bump-driven specular term isn't the right shape for "a few discrete bubbles." --dither bayertiles a 4x4 ordered-dither matrix across the texture — at small--size(the default 32, or smaller) that matrix only repeats a handful of times, so it can read as a busy checkerboard-like noise pattern rather than a smooth dithered gradient. This is the expected behavior of ordered dithering at low resolution, not a bug;--dither noisegives a less patterned (grainier) alternative, or drop--ditherentirely for hard bands with no dithering at all.--warp-strengthis in absolute pixels, not normalized to--size— if you change resolution, scale it proportionally or the relative distortion will quietly change.--alpha/--alpha-noise-strengthcontrol what the generated PNG encodes, not what Minecraft actually renders — an actual translucent appearance in-game still needs a translucent render type registered on the fluid's Java side. Without that, the PNG's alpha channel is simply ignored and the fluid renders fully opaque no matter what--alphasays, exactly like the removed--alphaflag this tool used to have before translucency support existed for real.










