Skip to content

Vertex painting - #63

Merged
LogicalError merged 27 commits into
RadicalCSG:mainfrom
LogicalError:vertex-painting
Oct 26, 2026
Merged

LogicalError merged 27 commits into
RadicalCSG:mainfrom
LogicalError:vertex-painting

Conversation

@LogicalError

@LogicalError LogicalError commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

Closes #36.

Vertex painting for Chisel surfaces: paint is written into vertex attributes (the color, or a texture coordinate) that the surface's shader reads, so what paint means is up to the shader.

Paint profiles

  • Project Settings > Chisel > Paint lists the shaders that can be painted, each with its modes: a color, or a named value with a range, written to an attribute's components (rgba/xyzw, rgb/xyz, or a single one). Value modes can form a weight group that always sums to one, for blending layers.
  • A material's Chisel section shows whether its shader is paintable, with a button that opens its profile, adding one when it has none.
  • Two test shaders (Chisel/Test/Paint Color, Chisel/Test/Paint Value) show color and value paint.

Paint on surfaces

  • Each painted surface keeps a grid of values in its own plane; the surface is subdivided at the grid (an incremental Delaunay triangulation, its outline cut where grid lines cross it), so the paint has vertices to land on.
  • The spacing of the grid is a surface setting (Paint Spacing); changing it resamples the paint.
  • A mesh drawn with a paintable shader always has that shader's attributes, at their defaults where nothing is painted.
  • Paint under a shader that doesn't take it is kept but not drawn, and comes back with one that does.

Chisel Paint tool

  • One of the Chisel tools. Its overlay has the modes and operations (paint, fill, add, subtract, smooth, erase) as wrapping toggles, color swatches (kept per user, reorderable), and radius, strength and hardness with a picture of the brush's falloff.
  • Control-click takes the paint under the mouse. A surface whose shader can't be painted shows a not-allowed cursor and says why.

Cost

  • Paint is kept by the tree between updates and replaced one generator at a time; only generators whose paint changed are looked at.
  • A paint change builds the painted brush only, not the brushes touching it (a new surfaces-only dirty flag).
  • Values are packed: a grid point keeps only the components its paint has, the color is 4 bytes in the mesh, and a texture coordinate only has floats up to its last painted component.

Paint profiles (ChiselPaintProfile) say what paint means to a shader: modes,
each a Color or a named Value, written to a vertex attribute (Color or
TexCoord2-7) in some of its components (xyzw, xyz, or one).

A generator keeps its surfaces' paint (ChiselSurfacePaint): per surface and
attribute a grid of values in the surface's plane, in its own space; the
cells are also the surface's subdivision. ChiselPaintManager gives each
model's tree its paint in the model's space (CSGTree.SetPaint) and rebuilds
the brushes whose paint changed.

A render mesh with painted surfaces takes the attributes painted on it in a
second vertex stream; its unpainted surfaces take the default. A mesh without
paint keeps its layout. The paint of a surface is part of its hash, so a
mesh that changes only in paint is not kept.

Test shaders and materials with their profiles: Chisel/Test/Paint Color
(vertex color) and Chisel/Test/Paint Value (TexCoord2.x).
Each grid point of a surface's paint inside the surface becomes a vertex
(PaintSubdivision), so the paint has vertices to land on: it is inserted as in
an incremental Delaunay triangulation, splitting the triangle it is in or the
two on the edge it is on, and the edges round it are flipped until the
triangles round it are Delaunay. Points on or near the outline, or near a
vertex, are left out, so the outline isn't cut and the surfaces it touches
get no T-junctions. Only surfaces in the grid's plane or parallel to it are
subdivided, at most 4096 points each.
The Chisel Paint tool (Tools > Chisel > Paint) paints the surface under the
mouse in a mode of the paint profile of its material's shader: a color, or a
named value, with a round brush (radius, strength, hardness) that paints,
adds, subtracts, smooths or erases. A surface painted for the first time gets
a grid over it of the tool's cell size (ChiselPaintBrush). Only the hit
surface is painted; a stroke is one undo. Fill Surface sets the whole
surface, control-click samples the paint under the mouse, and the channel
buttons copy, swap or invert components.

Modes in one group (ChiselPaintMode.group) are weights that sum to one:
painting one takes from the others in their proportion.
Picking renders, and the tool picked on every event, also inside the scene
view's layout and repaint ("GUI Window tried to begin rendering while
something else had not finished rendering"). It now picks on mouse move,
down and drag only, and draws what it last picked.

The panel was a GUI window, which needs the scene view's BeginWindows; it is
a plain area now, and shows the modes of the surface last painted on, so
they stay while the mouse is over the panel.
The settings were drawn straight into the scene view, see-through and not
like the other tool windows. They are a Chisel Paint overlay now, shown while
the tool is active.
A mode shows what its kind uses: a color mode its default color, a value mode
one default for its components, its range and its group; the components are
toggles, named RGBA for the Color attribute. A profile says when it has no
shader, and warns when two modes write one component of one attribute.

Also: FindObjectsByType without the obsolete sort mode in the decal editor.
A painted surface's outline is cut where the grid meets it (its points on it,
and where its lines cross it), so the surface is the grid's cells right up to
its edges instead of fans from its corners. A point is put on an outline edge
within a twentieth of a cell, on an edge inside only when it is on it.

Over a surface whose shader has no paint profile the tool shows no brush but a
not-allowed cursor, with a solid tooltip saying why; the overlay keeps one
width.
The profile registry (ChiselPaintProfiles) moved to the components, with
profiles registered in code next to the project's profile assets. Paint of a
surface whose material's shader has no profile with a mode in its attribute is
kept on the generator, but doesn't subdivide the surface or reach its mesh;
it comes back with a material that takes it.
The spacing of the points a surface is painted at is the surface's Paint
Spacing (ChiselSurface, beside its material: only painting reads it, so it
stays out of the details every brush polygon carries), shown for materials
whose shader has a paint profile. Changing it resamples the surface's paint
onto a grid of the new spacing.

The paint tool's overlay shows every mode of the project's profiles, by name,
as wrapping toggle buttons; a surface takes the chosen mode when its shader's
profile has one of that name. Over a surface that doesn't, the tooltip says
why, on three lines.
The paint profiles are plain data in one project asset (ChiselPaintSettings,
Assets/Chisel/ChiselPaintSettings.asset), edited in Project Settings > Chisel >
Paint: the shaders, each with its remove button, and the chosen one's modes.
A material's Chisel section says whether its shader is paintable, and its
Configure Paintability Of Shader button opens that page on the shader's
profile, adding one when it has none. The package's per-shader profile
assets are gone; a new settings asset starts with the test shaders.

The paint tool is one of the Chisel tools, with a brush icon. Its overlay
shows the operations as wrapping toggles with icons (Add and Subtract only
for a value), swatches and the recently painted colors under the color, and
a thumbnail of the blob a dab paints next to strength and hardness.
The color's swatches are kept per user and wrap over as many rows as they
need; the chosen one is outlined, each has a border, a drag reorders them
and a middle-click removes one. The last cell, the current color with a plus
over it, adds a swatch and opens Unity's color picker on it. The operations
are icon toggles (named in their tooltips) under Operations; the Mode label
is gone. The not-paintable cursor stays where the scene had the mouse, so it
isn't shown over the overlays. The settings page lists a profile's modes as
a list with a header instead of a foldout.
As in Unity's color picker: no plus, the current color drawn smaller than a
swatch inside an empty cell.
Swatches are 14 pixels and share their border with their neighbours; the
chosen one is outlined inside its cell.
Once per domain, before the first brush registration, the sizes and offsets
of BrushMeshBlob.Polygon, InternalChiselSurface and SurfaceDetails are
measured managed and in a Burst-compiled job; a difference is logged as one
error naming the fields. Growing SurfaceDetails once left code compiled for
the old layout writing blobs managed code misread (polygons 8 bytes short),
which broke every brush.
Fill is one of the operations (a bucket icon): clicking a surface sets all of
it to the color or value, and a stroke fills each surface it goes over once.
The brush settings are disabled while it is chosen; the Fill Surface button
is gone.

Swatches hold a color once, compared as they are kept (8 bits a component):
adding the current color when a swatch has it already chooses that swatch,
and a color picked into a new swatch that another swatch has takes that one.
The add cell takes only a left click. A right click on a swatch opens a menu
to delete it, for those without a middle mouse button. The swatch borders
are more subtle.
The icon script wrote them again under the old name with spaces; the tool
loads paint_tool.
The surface's own paintSpacing field showed again under the details drawer
(and for surfaces that can't be painted).
A surface render buffer kept the brush's entity with its painted surface
index; the entity is now the brush buffer's, once, and a surface keeps only
its index (-1 without paint). The paint lookup says whether there is any
paint with a flag set when it is made, rather than asking its hash map for
every surface. Comments that described removed parts (the Fill Surface
button, the cell size, the profile assets) are brought up to date.
A render mesh drawn with a material whose shader has a paint profile gets the
profile's attributes, at their modes' defaults where nothing is painted, so
the shader never reads an attribute the mesh lacks. Materials are registered
as surfaces take them (ChiselPaintMaterials, as the hotspot store does);
what their shaders take comes from the paint profiles, and a change to it
rebuilds every brush once, before the next build. A surface's hash takes its
material's paint attributes, so their meshes are written again.
Copying, swapping and inverting components of a surface's paint is gone from
the overlay, and with it from the paint brush.
The tree keeps its paint with the ranges and hashes an update looks up, made
when the paint changes rather than copied and hashed at every update; an
update reads views of it. Paint is replaced one entity at a time: only that
entity's layers are hashed, its old values are left behind until they are
half of all values, then compacted. The materials' paint attributes are
copied to the tree only when they change.
The triangle across an edge comes from a map of edges kept with the
triangles, and a point is found by walking from the triangle the last point
went into (the grid's points come row by row), rather than by scanning every
triangle for each; a point the walk doesn't find is still looked for in every
triangle. A grid at the most points a surface gets is tested too.
Paint doesn't change a brush's shape, so its brushes are marked with a new
surfaces-only flag (SetSurfacesDirty) instead of as reshaped: the brush is
built again, the brushes touching it aren't.
A generator says its paint changed (PaintChanged, its paint set or removed,
enabled, disabled, validated) and only those, and the painted generators that
moved, are given to their trees again, one entity at a time
(CSGTree.SetPaint with an entity). Every generator is looked at only when
what shaders take may have changed. A generator that loses all of its paint,
or is disabled, has its paint taken from the tree and its brushes built
again.
A grid point keeps only the components its paint has, in the generator's
grid, in the tree's values and in what a vertex samples (x, y, z, w in that
order), so the generator's values go to the tree as they are. In the render
mesh the color is 4 bytes (UNorm8) and a texture coordinate has floats up to
its last painted component; the materials' paint attributes carry their
components for that.
A surface whose grid isn't whole (its values didn't load) gets a new grid
when it is painted, rather than the brush stopping at it. The paint manager
only keeps the generators with paint, or whose paint a tree has, so a
destroyed model's meshes aren't kept loaded through the others.
@LogicalError
LogicalError merged commit d5e041c into RadicalCSG:main Oct 26, 2026
2 checks passed
@LogicalError
LogicalError deleted the vertex-painting branch October 26, 2026 09:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Vertex painting

1 participant