WORK / W-02

PaintForge
Paint. Sculpt. Scatter. One brush engine.

Vertex painting, sculpting and prefab scatter in one Unity 6 tool, all running on the same brush engine. Paint the mesh where it lives - no export, no round-trip through a DCC package, and none of the tool's code in the shipped game.

ROLE
Founder & Developer
STUDIO
Wirewolf Design s.r.o.
STACK
C# · Unity 6 · URP
UI Toolkit · HLSL
STATUS
v0.9.0 - unreleased
YEAR
2026
PaintForge - mesh painting, forged in-editor: vertex paint, sculpt and scatter
FIG.01 — PAINTING A MESH IN THE UNITY EDITOR

PaintForge is the second product from Wirewolf Design, and it comes from the same frustration as the first one: the work an artist wants to do is in the engine, but the tools live somewhere else. Blending two materials across a rock, softening a silhouette, scattering trees.

Unity's own Polybrush covered part of this and then stopped being maintained. PaintForge is a replacement built for Unity 6.

Each of those jobs is a loop out of the engine and back: export the mesh, paint or push vertices in a DCC package, export again, re-import, reassign the material - then look at it in context and find it needs another pass. The work takes minutes. The round-trip takes the afternoon.

BEFORE
export → paint in a DCC → export →
re-import → reassign → check in engine
WITH PAINTFORGE
paint it where it already is

Every mode consumes the same stroke pipeline. Raw pointer events pass through a screen-space stabilizer - a "lazy mouse" that string-pulls the brush behind the cursor so hand jitter dies before it reaches the surface. A sampler then converts the smoothed path into evenly spaced dabs, carrying leftover distance across pointer events so spacing stays constant however the input is chunked, with size, angle and position jitter drawn from a per-stroke reseeded generator. Each dab is raycast against a uniform-grid spatial cache of the mesh - Möller–Trumbore over persistent native arrays - and only then does the active mode decide what a hit means: color, displacement or a prefab.

The data side is defensive by design. Copy mode - the default - paints an instanced clone and serializes the paint separately, so the source asset is never modified; Direct mode writes the source only after a confirmation and an offered backup. Undo stores sparse diffs - touched vertex indices with byte-exact before/after values, one undo step per stroke across every object it touched - keyed by GlobalObjectId with a live-reference fallback, so history survives domain reloads. And every render texture or compute buffer the tool creates goes through a tracked pool that flushes, and names leaks, before each assembly reload.

// String pulling: the filtered position trails the raw pointer at the end of a
// taut string. Inside the radius nothing moves - hand jitter dies there.
public Vector2 Filter(Vector2 rawScreenPosition)
{
    Vector2 delta = rawScreenPosition - _current;
    float distance = delta.magnitude;
    if (distance > _radius && distance > 1e-6f)
    {
        _current = rawScreenPosition - delta * (_radius / distance);
    }
    return _current;
}
FIG.02 — THE LAZY MOUSE: THE WHOLE STABILIZER IS ONE TAUT STRING
FIG.03 — ONE BRUSH ENGINE, EVERY MODE: FROM POINTER TO UNDO RECORD
E/01
One engine, three modes

The same stabilizer, sampler and spatial cache drive vertex paint, sculpt and scatter. A new mode is a new operation on the same dabs, not a new engine - so a brush fix lands in every mode at once.

E/02
Sparse undo

A stroke commits touched indices with byte-exact before/after values - never a full-mesh snapshot - and a multi-object stroke is one undo step. Records resolve their target by GlobalObjectId with a live-reference fallback, so history survives domain reloads and recompiles.

E/03
Seam-safe sculpting

Five brushes - push/pull, smooth, flatten, inflate, grab - run on a position-welded adjacency map built in O(V + T): vertices duplicated along UV seams share a weld group and move as one, so smoothing can't tear the mesh open.

E/04
Non-destructive by default

Copy mode paints an instanced clone and keeps the paint serialized beside it; the source asset is never modified. Direct mode is opt-in and writes the source only after a confirmation and an offered backup.

E/05
Deterministic scatter

Placement math is pure and headless: uniform-in-disc samples from a seeded generator with a documented draw order, so the same stroke and seed reproduce byte-identical transforms. What it drops are ordinary prefab instances - no component riding on every object.

E/06
Painting posed characters

On a skinned mesh the brush lands where you see the surface, not on the bind pose: the spatial cache is baked from the current pose and re-baked only when a bone hash changes. Vertex order is preserved, so hits map back with zero remapping - and the non-skinned path stays byte-identical, pinned by a regression test.

The PaintForge window on the Vertex tab: brush size, hardness, opacity, flow and spacing, the falloff list open, RGBA channel buttons, colour palette and flood fill
FIG.04 — VERTEX PAINT: BRUSH, CHANNELS, PALETTE
The PaintForge window on the Sculpt tab: the shared brush settings above, with the sculpt brush set to Push Pull and its strength below
FIG.05 — SCULPT: BRUSH AND STRENGTH
The PaintForge window on the Scatter tab: prefab palette, density, minimum spacing, scale range, align to surface normal, random yaw, surface offset and the placement seed
FIG.06 — SCATTER: PREFAB PALETTE AND PLACEMENT RULES

The decision I care about most is what does not ship. PaintForge is an editor tool, so none of its code belongs in a player build: both assemblies are editor-only - even the scene component compiles out entirely. Shipping a painted mesh means baking it to a standalone asset (or back into the source FBX), and a build guard fails the build if anything painted is still un-baked - with the list of objects and the fix in the message, rather than a silent surprise in a shipped game.

3
MODES ON ONE BRUSH ENGINE — PAINT · SCULPT · SCATTER
0
TOOL SCRIPTS IN THE PLAYER BUILD
4
EDITOR-UI LANGUAGES — EN · 中文 · 日本語 · 한국어