From 73f1924db8c882253f05f957c425316c991b2825 Mon Sep 17 00:00:00 2001 From: Sander van Rossen Date: Mon, 26 Oct 2026 11:09:14 +0100 Subject: [PATCH 1/4] Saved meshes are kept only while their paint is the same The hash that decides whether a model keeps the meshes saved with its scene (and so what a build takes) has each generator's paint and what the shader of each of its materials takes of paint. Paint changed outside the editor, or a shader that takes paint differently, builds the model when the scene opens rather than keeping meshes with the old paint. --- Components/Managers/ChiselModelManager.cs | 58 ++++++++++++++++ Components/Tests/ChiselPaintTests.cs | 25 +++++++ Components/Tests/ChiselSavedOutputTests.cs | 78 ++++++++++++++++++++++ 3 files changed, 161 insertions(+) diff --git a/Components/Managers/ChiselModelManager.cs b/Components/Managers/ChiselModelManager.cs index e8fe79d6..ad37a4f4 100644 --- a/Components/Managers/ChiselModelManager.cs +++ b/Components/Managers/ChiselModelManager.cs @@ -828,6 +828,7 @@ public static Hash128 GetInputHash(ChiselModelComponent model) return default; AppendEnvironment(ref hash); AppendHotspotInputs(ref hash, model); + AppendPaintInputs(ref hash, model); return hash; } @@ -845,6 +846,7 @@ public static Hash128 GetComponentInputHash(ChiselModelComponent model) return default; AppendEnvironment(ref hash); AppendHotspotInputs(ref hash, model); + AppendPaintInputs(ref hash, model); return hash; } @@ -931,6 +933,62 @@ static void AppendHotspotInputs(ref Hash128 hash, ChiselModelComponent model) s_HotspotMaterials.Clear(); } + static readonly List s_PaintGenerators = new(); + static readonly List s_PaintMaterials = new(); + static readonly List s_PaintSlots = new(); + + // What the meshes take of paint: each generator's paint, and what the shader of each material its surfaces are + // drawn with takes (the attributes a mesh has even where nothing is painted) + static void AppendPaintInputs(ref Hash128 hash, ChiselModelComponent model) + { + s_PaintGenerators.Clear(); + s_PaintMaterials.Clear(); + model.GetComponentsInChildren(true, s_PaintGenerators); + foreach (var generator in s_PaintGenerators) + { + var paints = generator.SurfacePaint; + var paintCount = paints.Count; + hash.Append(ref paintCount); + foreach (var paint in paints) + { + if (paint == null) + continue; + var header = new Unity.Mathematics.int4(paint.surfaceIndex, (int)paint.attribute, (int)paint.components, paint.values?.Length ?? 0); + hash.Append(ref header); + var defaultValue = paint.defaultValue; hash.Append(ref defaultValue); + var origin = paint.origin; hash.Append(ref origin); + var cellU = paint.cellU; hash.Append(ref cellU); + var cellV = paint.cellV; hash.Append(ref cellV); + var resolution = paint.resolution; hash.Append(ref resolution); + if (paint.values != null && paint.values.Length > 0) + hash.Append(paint.values); + } + + var surfaces = generator.SurfaceDefinition?.surfaces; + if (surfaces == null) + continue; + foreach (var surface in surfaces) + { + var material = surface?.RenderMaterial; + if (!material || s_PaintMaterials.Contains(material)) + continue; + s_PaintMaterials.Add(material); + s_PaintSlots.Clear(); + ChiselPaintMaterials.Resolver?.Invoke(material, s_PaintSlots); + if (s_PaintSlots.Count == 0) + continue; + AppendMaterialIdentity(ref hash, material); + foreach (var slot in s_PaintSlots) + { + var key = new Unity.Mathematics.int2((int)slot.attribute, (int)slot.components); + hash.Append(ref key); + var defaultValue = slot.defaultValue; + hash.Append(ref defaultValue); + } + } + } + } + static void AppendMaterialIdentity(ref Hash128 hash, Material material) { var identity = material ? GetSurfaceParameterIdentity(EntityId.ToULong(material.GetEntityId())) : default; diff --git a/Components/Tests/ChiselPaintTests.cs b/Components/Tests/ChiselPaintTests.cs index e58f643b..895143c6 100644 --- a/Components/Tests/ChiselPaintTests.cs +++ b/Components/Tests/ChiselPaintTests.cs @@ -458,6 +458,31 @@ public void AGridWithoutItsValues_IsMadeAgainWhenPainted() Assert.That(box.GetSurfacePaint(top, ChiselPaintAttribute.Color), Is.SameAs(paint)); } + // A scene keeps its saved meshes only while their paint is the same: the paint and what the shaders take of it are + // in the hash that decides it + [Test] + public void TheSavedMeshesInputHash_HasThePaintAndWhatTheShaderTakes() + { + var model = Model(); + var box = Box(model, Vector3.zero); + Update(); + var unpainted = ChiselModelManager.GetComponentInputHash(model); + + var paint = PaintTop(box, ChiselPaintAttribute.Color, ChiselPaintComponents.XYZW); + Update(); + var painted = ChiselModelManager.GetComponentInputHash(model); + Assert.That(painted, Is.Not.EqualTo(unpainted), "painted"); + + paint[0, 0] = new Vector4(0.5f, 0.5f, 0.5f, 1); + Assert.That(ChiselModelManager.GetComponentInputHash(model), Is.Not.EqualTo(painted), "a value changed"); + var repainted = ChiselModelManager.GetComponentInputHash(model); + + ChiselPaintProfiles.Unregister(profile); + Assert.That(ChiselModelManager.GetComponentInputHash(model), Is.Not.EqualTo(repainted), "the shader takes no paint"); + ChiselPaintProfiles.Register(profile); + Assert.That(ChiselModelManager.GetComponentInputHash(model), Is.EqualTo(repainted), "and takes it again"); + } + // The grid moves with its generator [Test] public void Paint_MovesWithItsGenerator() diff --git a/Components/Tests/ChiselSavedOutputTests.cs b/Components/Tests/ChiselSavedOutputTests.cs index bec7cf95..9836cde3 100644 --- a/Components/Tests/ChiselSavedOutputTests.cs +++ b/Components/Tests/ChiselSavedOutputTests.cs @@ -223,6 +223,84 @@ public void AModelThatChangedWhileClosed_IsBuiltWhenItOpens() Assert.That(IsMarked(models[1]), Is.True); } + // Box 0's surfaces drawn with a material whose shader takes color paint, and its top painted red + ChiselSurfacePaint PaintBox0(out Material material, out ChiselPaintProfile profile) + { + material = new Material(Shader.Find("Universal Render Pipeline/Lit")); + profile = new ChiselPaintProfile { shader = material.shader, modes = { ChiselPaintMode.Color("Color") } }; + ChiselPaintProfiles.Register(profile); + foreach (var surface in boxes[0].SurfaceDefinition.surfaces) + surface.SetMaterial(material); + boxes[0].OnValidate(); + Update(); + var top = 0; + var brush = (CSGTreeBrush)boxes[0].TopTreeNode; + ref var mesh = ref BrushMeshManager.GetBrushMeshBlob(brush.BrushMesh).Value; + for (int s = 0; s < mesh.polygons.Length; s++) + { + if (mesh.localPlanes[s].y > 0.999f) + top = mesh.polygons[s].descriptionIndex; + } + var paint = ChiselPaintBrush.PaintFor(boxes[0], top, profile.modes[0]); + ChiselPaintBrush.Fill(paint, profile.modes[0], new Vector4(1, 0, 0, 1)); + boxes[0].PaintChanged(); + Update(); + return paint; + } + + static bool HasRed(ChiselModelComponent model) + { + foreach (var color in RenderableWithGeometry(model).sharedMesh.colors) + { + if (color.r > 0.99f && color.g < 0.01f) + return true; + } + return false; + } + + // A painted model keeps its saved meshes, and with them its paint + [Test] + public void APaintedModel_KeepsItsSavedMeshes_WithTheirPaint() + { + PaintBox0(out var material, out var profile); + try + { + Assert.That(HasRed(models[0]), Is.True, "painted"); + Save(); + MarkSavedMeshes(); + Reopen(); + Assert.That(CompactHierarchyManager.IsTreeUpdateSkipped(models[0].Node), Is.True); + Assert.That(IsMarked(models[0]), Is.True, "the painted model was built again"); + Assert.That(HasRed(models[0]), Is.True, "the saved mesh has the paint"); + } + finally + { + ChiselPaintProfiles.Unregister(profile); + UnityEngine.Object.DestroyImmediate(material); + } + } + + // Paint changed while the scene was closed is built when it opens + [Test] + public void PaintThatChangedWhileClosed_IsBuiltWhenItOpens() + { + var paint = PaintBox0(out var material, out var profile); + try + { + Save(); + MarkSavedMeshes(); + Reopen(whileClosed: () => ChiselPaintBrush.Fill(paint, profile.modes[0], new Vector4(0, 0, 1, 1))); + Assert.That(IsMarked(models[0]), Is.False, "the repainted model kept its old meshes"); + Assert.That(IsMarked(models[1]), Is.True); + Assert.That(HasRed(models[0]), Is.False, "the old paint"); + } + finally + { + ChiselPaintProfiles.Unregister(profile); + UnityEngine.Object.DestroyImmediate(material); + } + } + [Test] public void AModelSavedWithoutAHash_IsBuiltWhenItOpens() { From 72217f73168352d4ce4aaa658a4a1311fddd4aec Mon Sep 17 00:00:00 2001 From: Sander van Rossen Date: Mon, 26 Oct 2026 11:16:24 +0100 Subject: [PATCH 2/4] Paint: new values are drawn without building a brush When an entity's paint changes only in its values on the same grids, the values are written in place, the hashes of the surfaces they are on are put right in the brushes' cached buffers (the paint's part of a surface's hash is xor-ed in last for that), and the next update copies the meshes again without building a brush. New grids, a tree that kept its saved meshes, or brushes not built yet still have the brushes built. --- Components/Managers/ChiselPaintManager.cs | 7 +- Components/Tests/ChiselPaintTests.cs | 36 +++++ Core/1.Input/Paint/ChiselPaint.cs | 9 +- Core/2.Processing/Cache/ChiselLookup.cs | 2 + .../Jobs/GenerateSurfaceTrianglesJob.cs | 11 +- .../Managers/CSGManager.UpdateTreeMeshes.cs | 7 + Core/2.Processing/Paint/ChiselPaintStore.cs | 127 +++++++++++++++++- 7 files changed, 183 insertions(+), 16 deletions(-) diff --git a/Components/Managers/ChiselPaintManager.cs b/Components/Managers/ChiselPaintManager.cs index ac1cae02..3bca4dcb 100644 --- a/Components/Managers/ChiselPaintManager.cs +++ b/Components/Managers/ChiselPaintManager.cs @@ -86,7 +86,7 @@ static void Give(ChiselGeneratorComponent generator) if (given) { // built again without it, if it is still there - if (before.tree.Valid && before.tree.SetPaint(before.entityID, default, default) && generator) + if (before.tree.Valid && before.tree.SetPaint(before.entityID, default, default) != ChiselPaintChange.None && generator) SetBrushesDirty(generator.TopTreeNode); s_Given.Remove(generator); } @@ -136,8 +136,9 @@ static void Give(ChiselGeneratorComponent generator) v += paint.values.Length; } s_Given[generator] = new Given { entityID = entityID, tree = tree, space = space }; - // the brushes of a generator whose paint changed are built again, with it - if (tree.SetPaint(entityID, layers, values)) + // the brushes of a generator whose grids changed are built again, with them; new values on the same grids only + // have the meshes copied again + if (tree.SetPaint(entityID, layers, values) == ChiselPaintChange.Grids) SetBrushesDirty(generator.TopTreeNode); } diff --git a/Components/Tests/ChiselPaintTests.cs b/Components/Tests/ChiselPaintTests.cs index 895143c6..ac21ec55 100644 --- a/Components/Tests/ChiselPaintTests.cs +++ b/Components/Tests/ChiselPaintTests.cs @@ -483,6 +483,42 @@ public void TheSavedMeshesInputHash_HasThePaintAndWhatTheShaderTakes() Assert.That(ChiselModelManager.GetComponentInputHash(model), Is.EqualTo(repainted), "and takes it again"); } + // The buffer the box's brush was last built into + static Unity.Entities.BlobAssetReference BuiltBuffer(ChiselModelComponent model, ChiselBoxComponent box) + { + var entityID = EntityId.ToULong(box.GetEntityId()); + var cache = ChiselTreeLookup.Value[model.Node].brushRenderBufferCache; + for (int b = 0; b < cache.Length; b++) + { + if (cache[b].IsCreated && cache[b].Value.entityID == entityID) + return cache[b]; + } + Assert.Fail("the box's brush has no buffer"); + return default; + } + + // New values on the same grid are copied into the mesh without building the brush again; a new grid builds it + [Test] + public void NewValues_AreDrawnWithoutBuildingTheBrush() + { + var model = Model(); + var box = Box(model, Vector3.zero); + var paint = PaintTop(box, ChiselPaintAttribute.Color, ChiselPaintComponents.XYZW); + Update(); + var built = BuiltBuffer(model, box); + + paint[0, 0] = new Vector4(0, 1, 1, 1); + box.PaintChanged(); + Update(); + AssertValuesAt(RenderMesh(model), VertexAttribute.Color, new Vector3(0, 1, 0), new Vector4(0, 1, 1, 1), "the new value"); + Assert.That(BuiltBuffer(model, box), Is.EqualTo(built), "the brush was built again for new values"); + + box.SetSurfacePaint(ChiselSurfacePaint.Create(paint.surfaceIndex, paint.attribute, paint.components, Vector4.one, + paint.origin, paint.cellU * 0.5f, paint.cellV * 0.5f, new Vector2Int(3, 3))); + Update(); + Assert.That(BuiltBuffer(model, box), Is.Not.EqualTo(built), "a new grid didn't build the brush"); + } + // The grid moves with its generator [Test] public void Paint_MovesWithItsGenerator() diff --git a/Core/1.Input/Paint/ChiselPaint.cs b/Core/1.Input/Paint/ChiselPaint.cs index 07279bd4..aa1155d5 100644 --- a/Core/1.Input/Paint/ChiselPaint.cs +++ b/Core/1.Input/Paint/ChiselPaint.cs @@ -148,12 +148,13 @@ public static bool SetPaint(this CSGTree tree, NativeArray lay } /// - /// Replaces the paint of one entity: its layers, and the values their grids take from. True when it changed; the - /// entity's brushes then have to be rebuilt to show it (). + /// Replaces the paint of one entity: its layers, and the values their grids take from. When only values changed on + /// the same grids the meshes are copied again at the next update without building a brush; when the grids changed + /// the entity's brushes have to be built again to show it (). /// - public static bool SetPaint(this CSGTree tree, ulong entityID, NativeArray layers, NativeArray values) + public static ChiselPaintChange SetPaint(this CSGTree tree, ulong entityID, NativeArray layers, NativeArray values) { - return ChiselPaintStore.SetPaint(tree, entityID, layers, values); + return ChiselPaintStore.SetPaintOrValues(tree, entityID, layers, values); } } } diff --git a/Core/2.Processing/Cache/ChiselLookup.cs b/Core/2.Processing/Cache/ChiselLookup.cs index 06b32056..8c64d463 100644 --- a/Core/2.Processing/Cache/ChiselLookup.cs +++ b/Core/2.Processing/Cache/ChiselLookup.cs @@ -49,6 +49,8 @@ public class Data public NativeList paintMaterialSlots; public NativeParallelHashMap paintMaterials; public int paintMaterialsVersion = -1; + // paint values changed in place: the meshes are copied again without building a brush + public bool paintValuesChanged; public NativeParallelHashMap> brushRenderBufferLookup; diff --git a/Core/2.Processing/Jobs/GenerateSurfaceTrianglesJob.cs b/Core/2.Processing/Jobs/GenerateSurfaceTrianglesJob.cs index 7b2f52bd..f3de6a2c 100644 --- a/Core/2.Processing/Jobs/GenerateSurfaceTrianglesJob.cs +++ b/Core/2.Processing/Jobs/GenerateSurfaceTrianglesJob.cs @@ -312,16 +312,17 @@ unsafe void FinishSurface(int surf, int brushNodeOrder, ulong entityID, ref Blob buf.hotspot = hotspotInfo; buf.hotspotSource = hotspotSource; - // paint is found by the brush's entity and the surface's index, when the surface's mesh is copied + // the attributes its material's shader takes paint in are in its mesh, painted or not + if (paint.TryGetMaterial(parms.parameter1, out var materialRange)) + buf.surfaceHashValue = math.hash(new uint2(buf.surfaceHashValue, materialRange.hash)); + // paint is found by the brush's entity and the surface's index, when the surface's mesh is copied; its hash + // goes in last, by xor, so new values can replace it in place (ChiselPaintStore.SetValues) var paintSurface = baseSurfaces[surf].descriptionIndex; if (paint.TryGetRange(entityID, paintSurface, out var paintRange)) { buf.paintSurface = paintSurface; - buf.surfaceHashValue = math.hash(new uint2(buf.surfaceHashValue, paintRange.hash)); + buf.surfaceHashValue ^= paintRange.hash; } - // the attributes its material's shader takes paint in are in its mesh, painted or not - if (paint.TryGetMaterial(parms.parameter1, out var materialRange)) - buf.surfaceHashValue = math.hash(new uint2(buf.surfaceHashValue, materialRange.hash)); if (HasBackFace(flags, outputFlags)) FinishBackFace(surf, entityID, flags, parms, outputFlags, surfaceIndexList, ref uniqueVertexMapper, builder, surfaceBuffers, slots); diff --git a/Core/2.Processing/Managers/CSGManager.UpdateTreeMeshes.cs b/Core/2.Processing/Managers/CSGManager.UpdateTreeMeshes.cs index 60f93bde..6f56e470 100644 --- a/Core/2.Processing/Managers/CSGManager.UpdateTreeMeshes.cs +++ b/Core/2.Processing/Managers/CSGManager.UpdateTreeMeshes.cs @@ -189,6 +189,8 @@ internal struct TreeUpdate public int maxNodeOrder; public int updateCount; public bool brushListChanged; + // only paint values changed: the meshes are copied again from the brushes' buffers (ChiselPaintStore) + public bool paintValuesChanged; // RunMeshUpdateJobs ran for this update (RunMeshCopyJobs follows) public bool ranMeshUpdate; public bool subtractiveWorkflow; @@ -736,6 +738,9 @@ public static JobHandle ScheduleTreeMeshJobs(FinishMeshUpdate finishMeshUpdates, treeUpdate.treeCompactNodeID = treeCompactNodeID; treeUpdate.ranMeshUpdate = false; treeUpdate.brushListChanged = false; + var lookup = ChiselTreeLookup.Value[tree]; + treeUpdate.paintValuesChanged = lookup.paintValuesChanged; + lookup.paintValuesChanged = false; treeUpdateLength++; } @@ -806,6 +811,7 @@ public static JobHandle ScheduleTreeMeshJobs(FinishMeshUpdate finishMeshUpdates, if (treeUpdate.updateCount <= 0 && !treeUpdate.brushListChanged && + !(treeUpdate.paintValuesChanged && treeUpdate.brushCount > 0) && !(s_RemakeAll && treeUpdate.brushCount > 0)) continue; @@ -876,6 +882,7 @@ public static JobHandle ScheduleTreeMeshJobs(FinishMeshUpdate finishMeshUpdates, if (treeUpdate.updateCount <= 0 && !treeUpdate.brushListChanged && + !treeUpdate.paintValuesChanged && treeUpdate.brushCount > 0 && !s_RemakeAll) continue; diff --git a/Core/2.Processing/Paint/ChiselPaintStore.cs b/Core/2.Processing/Paint/ChiselPaintStore.cs index d4e8247e..70e61080 100644 --- a/Core/2.Processing/Paint/ChiselPaintStore.cs +++ b/Core/2.Processing/Paint/ChiselPaintStore.cs @@ -63,6 +63,16 @@ public static ChiselPaintLookup Of(ChiselTreeLookup.Data data) } } + /// What setting an entity's paint changed + public enum ChiselPaintChange : byte + { + None, + /// Only values on the same grids: the meshes are copied again, no brush is built + Values, + /// The grids (or which surfaces have paint): the entity's brushes have to be built again + Grids + } + // The tree keeps its paint between updates: each entity's layers together, their values appended as they come (the // values of replaced layers are left until they are half of them), and the ranges and hashes an update looks up internal static class ChiselPaintStore @@ -87,10 +97,25 @@ public static unsafe uint HashOf(in ChiselPaintLayer layer, float* values) /// Replaces the paint of one entity: its layers, and the values their grids take from. True when it changed; /// its brushes then have to be rebuilt to show it. - public static unsafe bool SetPaint(CSGTree tree, ulong entityID, NativeArray layers, NativeArray values) + public static bool SetPaint(CSGTree tree, ulong entityID, NativeArray layers, NativeArray values) + { + return Set(tree, entityID, layers, values, false) != ChiselPaintChange.None; + } + + /// + /// Replaces the paint of one entity. When only values changed on the same grids, they are written in place and + /// the meshes are copied again at the next update without building a brush (); + /// otherwise the entity's brushes have to be built again (). + /// + public static ChiselPaintChange SetPaintOrValues(CSGTree tree, ulong entityID, NativeArray layers, NativeArray values) + { + return Set(tree, entityID, layers, values, true); + } + + static unsafe ChiselPaintChange Set(CSGTree tree, ulong entityID, NativeArray layers, NativeArray values, bool valuesInPlace) { if (!tree.Valid) - return false; + return ChiselPaintChange.None; var data = ChiselTreeLookup.Value[tree]; NativeList sorted; @@ -116,14 +141,22 @@ public static unsafe bool SetPaint(CSGTree tree, ulong entityID, NativeArray 0 && data.paintUnusedValues * 2 >= data.paintValues.Length) Compact(data); UpdateRanges(data); + return ChiselPaintChange.Grids; + } + + // Whether the entity's layers from first are the new layers but for their values + static bool SameGrids(ChiselTreeLookup.Data data, int first, NativeList layers) + { + for (int l = 0; l < layers.Length; l++) + { + if (first + l >= data.paintLayers.Length) + return false; + var old = data.paintLayers[first + l]; + var layer = layers[l]; + if (old.entityID != layer.entityID) + return false; + old.valueStart = layer.valueStart = 0; + if (!old.Equals(layer)) + return false; + } + var next = first + layers.Length; + return layers.Length > 0 && (next >= data.paintLayers.Length || data.paintLayers[next].entityID != layers[0].entityID); + } + + // New values written over the old, and the hashes of the surfaces they are on put right in the brushes' cached + // buffers, so the next update copies their meshes again without building a brush. False when no buffer of the + // entity's brushes has the paint (they are built then) + static unsafe bool SetValues(CSGTree tree, ChiselTreeLookup.Data data, ulong entityID, int first, NativeList layers, + NativeList hashes, float* values) + { + for (int l = 0; l < layers.Length; l++) + { + var old = data.paintLayers[first + l]; + UnsafeUtility.MemCpy((float*)data.paintValues.GetUnsafePtr() + old.valueStart, values + layers[l].valueStart, old.ValueCount * sizeof(float)); + data.paintLayerHashes[first + l] = hashes[l]; + } + + // each surface's new hash, and what changes in the hash of the surfaces it is on + using var changes = new NativeParallelHashMap(math.max(1, layers.Length), Allocator.Temp); + for (int start = first; start < first + layers.Length;) + { + var surfaceIndex = data.paintLayers[start].surfaceIndex; + var key = new ChiselPaintKey { entityID = entityID, surfaceIndex = surfaceIndex }; + data.paintRanges.TryGetValue(key, out var range); + var hash = 0u; + var end = start; + for (; end < first + layers.Length && data.paintLayers[end].surfaceIndex == surfaceIndex; end++) + hash = math.hash(new uint2(hash, data.paintLayerHashes[end])); + if (hash != range.hash) + changes.TryAdd(surfaceIndex, hash ^ range.hash); + range.hash = hash; + data.paintRanges[key] = range; + start = end; + } + if (changes.IsEmpty) + return true; + + var found = false; + var cache = data.brushRenderBufferCache; + for (int b = 0; b < cache.Length; b++) + { + if (!cache[b].IsCreated) + continue; + ref var buffer = ref cache[b].Value; + if (buffer.entityID != entityID) + continue; + for (int s = 0; s < buffer.surfaces.Length; s++) + { + ref var surface = ref buffer.surfaces[s]; + if (surface.paintSurface < 0 || !changes.TryGetValue(surface.paintSurface, out var change)) + continue; + found = true; + surface.surfaceHashValue ^= change; + for (int q = 0; q < buffer.querySurfaces.Length; q++) + { + ref var querySurfaces = ref buffer.querySurfaces[q].surfaces; + for (int i = 0; i < querySurfaces.Length; i++) + { + if (querySurfaces[i].surfaceIndex == s) + querySurfaces[i].surfaceHashValue ^= change; + } + } + } + } + if (!found) + return false; + data.paintValuesChanged = true; + tree.SetDirty(); return true; } From 0dff6ddfb8fc42e89749093fc9043ba103161afb Mon Sep 17 00:00:00 2001 From: Sander van Rossen Date: Mon, 26 Oct 2026 11:22:52 +0100 Subject: [PATCH 3/4] The saved-mesh hash doesn't hold on to a model's paint The lists the paint part of the hash is gathered in are cleared after it, so a destroyed model's meshes aren't kept loaded through them. --- Components/Managers/ChiselModelManager.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Components/Managers/ChiselModelManager.cs b/Components/Managers/ChiselModelManager.cs index ad37a4f4..ee755624 100644 --- a/Components/Managers/ChiselModelManager.cs +++ b/Components/Managers/ChiselModelManager.cs @@ -987,6 +987,10 @@ static void AppendPaintInputs(ref Hash128 hash, ChiselModelComponent model) } } } + // nothing of the model is held on to + s_PaintGenerators.Clear(); + s_PaintMaterials.Clear(); + s_PaintSlots.Clear(); } static void AppendMaterialIdentity(ref Hash128 hash, Material material) From 226ff7217bc7813043c5ad6339457d9f7084379c Mon Sep 17 00:00:00 2001 From: Sander van Rossen Date: Mon, 26 Oct 2026 12:00:08 +0100 Subject: [PATCH 4/4] Paint: what a material takes is compared per material Only a material resolved before whose paint attributes changed has every brush built again; a material resolved for the first time (every one, when the editor starts) is in the brushes being built with it, so models keep their saved meshes when a scene opens. The paint manager's work set is a set, emptied after each update so it holds no generator between updates. --- Components/Managers/ChiselPaintManager.cs | 9 ++---- Core/1.Input/Paint/ChiselPaintMaterials.cs | 34 ++++++++++++++++------ 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/Components/Managers/ChiselPaintManager.cs b/Components/Managers/ChiselPaintManager.cs index 3bca4dcb..a402bdf8 100644 --- a/Components/Managers/ChiselPaintManager.cs +++ b/Components/Managers/ChiselPaintManager.cs @@ -24,7 +24,7 @@ struct Given static bool s_All = true; static readonly HashSet s_Changed = new(); static readonly Dictionary s_Given = new(); - static readonly List s_Work = new(); + static readonly HashSet s_Work = new(); /// Every generator's paint is looked at again (what shaders take may have changed) public static void SetDirty() => s_All = true; @@ -64,15 +64,12 @@ public static void Update() } s_All = false; } - foreach (var generator in s_Changed) - { - if (!s_Work.Contains(generator)) - s_Work.Add(generator); - } + s_Work.UnionWith(s_Changed); s_Changed.Clear(); foreach (var generator in s_Work) Give(generator); + s_Work.Clear(); } // The generator's paint given to its model's tree, and taken from the tree it was in before diff --git a/Core/1.Input/Paint/ChiselPaintMaterials.cs b/Core/1.Input/Paint/ChiselPaintMaterials.cs index b719039a..2a102539 100644 --- a/Core/1.Input/Paint/ChiselPaintMaterials.cs +++ b/Core/1.Input/Paint/ChiselPaintMaterials.cs @@ -28,7 +28,8 @@ public static class ChiselPaintMaterials static readonly Dictionary s_Materials = new(); static readonly List s_Slots = new(); static readonly List s_Resolved = new(); - static readonly List s_Scratch = new(); + static readonly Dictionary s_Before = new(); + static readonly Dictionary s_After = new(); static readonly List s_Gone = new(); static bool s_Dirty; static int s_Version; @@ -45,22 +46,28 @@ public static void Register(Material material) /// What shaders take may have changed; looked into at the next public static void Changed() => s_Changed = true; - /// Before the trees are built: every brush is built again when a registered material's attributes changed + /// Before the trees are built: every brush is built again when what a material already resolved takes + /// changed (a material resolved for the first time is in the brushes being built with it) public static void Update() { if (!s_Changed) return; s_Changed = false; s_Dirty = true; - var before = s_Slots.Count; - s_Scratch.Clear(); - s_Scratch.AddRange(s_Slots); + s_Before.Clear(); + HashByMaterial(s_Slots, s_Before); Resolve(); - var same = before == s_Slots.Count; - for (int i = 0; same && i < before; i++) + s_After.Clear(); + HashByMaterial(s_Slots, s_After); + var same = true; + foreach (var entry in s_Before) { - same = s_Scratch[i].material == s_Slots[i].material && s_Scratch[i].attribute == s_Slots[i].attribute && - s_Scratch[i].components == s_Slots[i].components && s_Scratch[i].defaultValue.Equals(s_Slots[i].defaultValue); + s_After.TryGetValue(entry.Key, out var after); + if (after != entry.Value) + { + same = false; + break; + } } if (same) return; @@ -73,6 +80,15 @@ public static void Update() } } + static void HashByMaterial(List slots, Dictionary hashes) + { + foreach (var slot in slots) + { + hashes.TryGetValue(slot.material, out var hash); + hashes[slot.material] = math.hash(new uint4(hash, (uint)slot.attribute, (uint)slot.components, math.hash(math.asuint(slot.defaultValue)))); + } + } + static void SetBrushesDirty(CSGTreeNode node) { if (!node.Valid)