From a845625d5975540c9a363827698ef1d4e2dfd484 Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Sat, 27 Dec 2025 07:51:25 +0000 Subject: [PATCH 01/11] Add shared material cache with reference counting --- api/material.js | 135 +++++++++++++++++++++++++++++++++++++++++++----- api/scene.js | 56 +++++++++++++------- 2 files changed, 159 insertions(+), 32 deletions(-) diff --git a/api/material.js b/api/material.js index d375473b..21f051d9 100644 --- a/api/material.js +++ b/api/material.js @@ -1,7 +1,72 @@ let flock; +const materialCache = new Map(); +const materialCacheKeys = new WeakMap(); + +const normaliseColorInput = (input) => { + const asHex = (value) => { + if (typeof value === "string") return flock.getColorFromString(value); + if (value instanceof flock?.BABYLON?.Color3) return value.toHexString(); + if (value instanceof flock?.BABYLON?.Color4) + return new flock.BABYLON.Color3(value.r, value.g, value.b).toHexString(); + if ( + value && + typeof value === "object" && + typeof value.r === "number" && + typeof value.g === "number" && + typeof value.b === "number" + ) { + const col = new flock.BABYLON.Color3(value.r, value.g, value.b); + return col.toHexString(); + } + return String(value ?? ""); + }; + + if (Array.isArray(input)) return input.map((v) => asHex(v)); + if (input == null) return null; + return asHex(input); +}; + +const normaliseTiling = (tiling) => { + if (tiling == null) return null; + if (typeof tiling === "number") return { unitsPerTile: tiling }; + if (typeof tiling === "object") { + const { uScale, vScale, wrapU, wrapV, unitsPerTile } = tiling; + return { uScale, vScale, wrapU, wrapV, unitsPerTile }; + } + return { value: tiling }; +}; + +const descriptorKey = (descriptor = {}) => { + const { materialName = "", color = null, alpha = 1, tiling = null } = + descriptor; + return JSON.stringify({ + materialName, + color: normaliseColorInput(color), + alpha: Number.isFinite(alpha) ? alpha : 1, + tiling: normaliseTiling(tiling), + }); +}; + +const registerCachedMaterial = (material, key) => { + material.metadata = material.metadata || {}; + material.metadata.sharedMaterial = true; + material.metadata.cacheKey = key; + materialCache.set(key, { material, refCount: 1 }); + materialCacheKeys.set(material, key); +}; + +const incrementCacheRef = (key) => { + const entry = materialCache.get(key); + if (entry) entry.refCount += 1; + return entry?.material ?? null; +}; + export function setFlockReference(ref) { flock = ref; + flock.materialCacheKey = descriptorKey; + flock.acquireCachedMaterial = incrementCacheRef; + flock.registerCachedMaterial = registerCachedMaterial; } export const flockMaterial = { @@ -197,6 +262,31 @@ export const flockMaterial = { } }, + releaseMaterial(material, { forceDispose = false } = {}) { + if (!material) return; + const key = materialCacheKeys.get(material) || material.metadata?.cacheKey; + if (key && materialCache.has(key)) { + const entry = materialCache.get(key); + entry.refCount = Math.max(0, (entry.refCount || 0) - 1); + + if (entry.refCount === 0) { + materialCache.delete(key); + materialCacheKeys.delete(material); + if (Array.isArray(flock.scene?.materials)) { + flock.scene.materials = flock.scene.materials.filter( + (mat) => mat !== material, + ); + } + material.dispose?.(); + } + return; + } + + if (forceDispose) { + material.dispose?.(); + } + }, + tint(meshName, { color } = {}) { if (flock.materialsDebug) console.log(`Changing tint of ${meshName} by ${color}`); @@ -376,19 +466,26 @@ export const flockMaterial = { // Iterate through all collected meshes allMeshes.forEach((currentMesh) => { if (currentMesh.material && currentMesh.metadata?.sharedMaterial) { + const originalMaterial = currentMesh.material; // Check if the material has already been cloned - if (!materialMapping.has(currentMesh.material)) { + if (!materialMapping.has(originalMaterial)) { // Clone the material and store it in the mapping if (flock.materialsDebug) console.log( - ` Cloning material, ${currentMesh.material}, of ${currentMesh.name}`, + ` Cloning material, ${originalMaterial}, of ${currentMesh.name}`, ); - const clonedMaterial = cloneMaterial(currentMesh.material); - materialMapping.set(currentMesh.material, clonedMaterial); + const clonedMaterial = cloneMaterial(originalMaterial); + clonedMaterial.metadata = { + ...(clonedMaterial.metadata || {}), + sharedMaterial: false, + cacheKey: undefined, + }; + materialMapping.set(originalMaterial, clonedMaterial); } // Assign the cloned material to the current mesh - currentMesh.material = materialMapping.get(currentMesh.material); + currentMesh.material = materialMapping.get(originalMaterial); + flock.releaseMaterial(originalMaterial, { forceDispose: false }); currentMesh.metadata.sharedMaterial = false; // Material is now unique to this hierarchy } }); @@ -793,7 +890,7 @@ export const flockMaterial = { const allMeshes = [mesh].concat(mesh.getDescendants()); allMeshes.forEach((part) => { if (part.material?.metadata?.internal) { - part.material.dispose(); + flock.releaseMaterial(part.material, { forceDispose: true }); } }); @@ -844,8 +941,16 @@ export const flockMaterial = { } }); }, - createMaterial({ color, materialName, alpha } = {}) { + createMaterial({ color, materialName, alpha, tiling } = {}) { if (flock?.materialsDebug) console.log(`Create material: ${materialName}`); + const resolvedAlpha = alpha ?? 1; + const descriptor = { color, materialName, alpha: resolvedAlpha, tiling }; + const key = descriptorKey(descriptor); + const existing = incrementCacheRef(key); + if (existing) { + return existing; + } + let material; const texturePath = flock.texturePath + materialName; @@ -896,15 +1001,16 @@ export const flockMaterial = { material.backFaceCulling = false; } - material.alpha = alpha; + material.alpha = resolvedAlpha; // Update alpha for shader materials - if (material.setFloat && alpha !== undefined) { - material.setFloat("alpha", alpha); + if (material.setFloat && resolvedAlpha !== undefined) { + material.setFloat("alpha", resolvedAlpha); } if (flock.materialsDebug) console.log(`Created the material: ${material.name}`); + registerCachedMaterial(material, key); return material; }, createMultiColorGradientMaterial(name, colors) { @@ -1756,10 +1862,11 @@ export const flockMaterial = { } // --- Default N-colour fallback (treat as uniform) ------------------------- - const material = new flock.BABYLON.StandardMaterial( - `${shapeType.toLowerCase()}Material`, - scene, - ); + const material = flock.createMaterial({ + materialName: `${shapeType.toLowerCase()}Material`, + color: color[0], + alpha: resolvedAlpha, + }); material.diffuseColor = flock.BABYLON.Color3.FromHexString( flock.getColorFromString(color[0]), ); diff --git a/api/scene.js b/api/scene.js index 5f542ef7..391e8b80 100644 --- a/api/scene.js +++ b/api/scene.js @@ -403,23 +403,42 @@ export const flockScene = { if (material && material instanceof flock.BABYLON.Material) { applyMapMaterial(material); } else if (Array.isArray(material) && material.length >= 2) { - const mat = new flock.BABYLON.StandardMaterial( - "mapGradientMat", - flock.scene, - ); - const dt = flock.createLinearGradientTexture(material, { - size: 1024, - horizontal: false, + const cacheKey = flock.materialCacheKey?.({ + materialName: "mapGradientMat", + color: material, + alpha: 1, + tiling: { + wrapU: flock.BABYLON.Texture.CLAMP_ADDRESSMODE, + wrapV: flock.BABYLON.Texture.CLAMP_ADDRESSMODE, + uScale: 1, + vScale: 1, + }, }); - mat.diffuseTexture = dt; - mat.specularColor = new flock.BABYLON.Color3(0, 0, 0); - mat.backFaceCulling = true; + const cached = cacheKey ? flock.acquireCachedMaterial(cacheKey) : null; - // Clamp so the gradient spans the plane once - mat.diffuseTexture.wrapU = flock.BABYLON.Texture.CLAMP_ADDRESSMODE; - mat.diffuseTexture.wrapV = flock.BABYLON.Texture.CLAMP_ADDRESSMODE; - mat.diffuseTexture.uScale = 1; - mat.diffuseTexture.vScale = 1; + const mat = cached || + (() => { + const newMat = new flock.BABYLON.StandardMaterial( + "mapGradientMat", + flock.scene, + ); + const dt = flock.createLinearGradientTexture(material, { + size: 1024, + horizontal: false, + }); + newMat.diffuseTexture = dt; + newMat.specularColor = new flock.BABYLON.Color3(0, 0, 0); + newMat.backFaceCulling = true; + + // Clamp so the gradient spans the plane once + newMat.diffuseTexture.wrapU = flock.BABYLON.Texture.CLAMP_ADDRESSMODE; + newMat.diffuseTexture.wrapV = flock.BABYLON.Texture.CLAMP_ADDRESSMODE; + newMat.diffuseTexture.uScale = 1; + newMat.diffuseTexture.vScale = 1; + + if (cacheKey) flock.registerCachedMaterial(newMat, cacheKey); + return newMat; + })(); applyMapMaterial(mat); } else if (material) { @@ -553,12 +572,13 @@ export const flockScene = { // Dispose material if not already disposed if (!disposedMaterials.has(material)) { + disposedMaterials.add(material); const sharedMaterial = currentMesh.metadata?.sharedMaterial; const internalMaterial = material.metadata?.internal; - if (sharedMaterial === false && internalMaterial === true) { - disposedMaterials.add(material); - + if (material.metadata?.cacheKey) { + flock.releaseMaterial(material); + } else if (sharedMaterial === false && internalMaterial === true) { // Remove from scene.materials flock.scene.materials = flock.scene.materials.filter( (mat) => mat !== material, From cb194a3252a9fd25ef266a5076fd5c77e72c5650 Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Sat, 27 Dec 2025 08:02:26 +0000 Subject: [PATCH 02/11] Fix material cache reuse for plain colors --- api/material.js | 24 ++++++++++++++++++++++++ api/scene.js | 12 ++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/api/material.js b/api/material.js index 21f051d9..3981ce21 100644 --- a/api/material.js +++ b/api/material.js @@ -1436,6 +1436,26 @@ export const flockMaterial = { } }; + const isPlainColorValue = (c) => + typeof c === "string" || + c instanceof flock.BABYLON.Color3 || + c instanceof flock.BABYLON.Color4; + + const tryApplyPlainColor = () => { + if (Array.isArray(color) || !isPlainColorValue(color)) return false; + + const mat = flock.createMaterial({ + materialName: "none.png", + color, + alpha: resolvedAlpha, + }); + + disposePlaneSideMaterials(); + clearVertexColors(); + applyMaterialWithTilingIfAny(mat); + return true; + }; + // --- Material path for objects that can take a single material ------------ const materialFromArray = Array.isArray(color) && @@ -1459,6 +1479,10 @@ export const flockMaterial = { } } + if (tryApplyPlainColor()) { + return; + } + // Plane: allow a material or a material descriptor (or array -> first) if (shapeType === "Plane") { let matCandidate = null; diff --git a/api/scene.js b/api/scene.js index 391e8b80..6ee03ea2 100644 --- a/api/scene.js +++ b/api/scene.js @@ -504,13 +504,21 @@ export const flockScene = { } if (mesh.name === "ground") { - mesh.material?.dispose(); + if (mesh.material?.metadata?.cacheKey) { + flock.releaseMaterial(mesh.material); + } else { + mesh.material?.dispose(); + } mesh.dispose(); flock.ground = null; return; } if (mesh.name === "sky") { - mesh.material?.dispose(); + if (mesh.material?.metadata?.cacheKey) { + flock.releaseMaterial(mesh.material); + } else { + mesh.material?.dispose(); + } mesh.dispose(); flock.sky = null; return; From ffd47769d4df007343661f78cc449e1da88dc4e7 Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Sat, 27 Dec 2025 08:08:23 +0000 Subject: [PATCH 03/11] Cache imported model materials --- api/material.js | 73 +++++++++++++++++++++++++++++++++++++++++++++++++ api/models.js | 4 +++ api/scene.js | 9 ++++-- 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/api/material.js b/api/material.js index 3981ce21..def24cd7 100644 --- a/api/material.js +++ b/api/material.js @@ -2,6 +2,7 @@ let flock; const materialCache = new Map(); const materialCacheKeys = new WeakMap(); +const cachedMaterialMeshes = new WeakSet(); const normaliseColorInput = (input) => { const asHex = (value) => { @@ -56,6 +57,76 @@ const registerCachedMaterial = (material, key) => { materialCacheKeys.set(material, key); }; +const describeStandardMaterial = (material) => { + if (!material || !(material instanceof flock?.BABYLON?.StandardMaterial)) return null; + + const tex = material.diffuseTexture; + const tiling = tex + ? { + uScale: tex.uScale, + vScale: tex.vScale, + wrapU: tex.wrapU, + wrapV: tex.wrapV, + } + : null; + + const color = material.diffuseColor?.toHexString?.() ?? null; + + return { + materialName: tex?.name || material.name || "", + color, + alpha: material.alpha ?? 1, + tiling, + }; +}; + +const cacheExistingMaterial = (material) => { + if (!material) return material; + + const existingKey = + materialCacheKeys.get(material) || material.metadata?.cacheKey || null; + + if (existingKey && materialCache.has(existingKey)) { + material.metadata = material.metadata || {}; + material.metadata.sharedMaterial = true; + incrementCacheRef(existingKey); + return materialCache.get(existingKey).material; + } + + const descriptor = describeStandardMaterial(material); + if (!descriptor) return material; + + const key = descriptorKey(descriptor); + const existing = incrementCacheRef(key); + if (existing) { + material.dispose?.(); + return existing; + } + + registerCachedMaterial(material, key); + return material; +}; + +const cacheMaterialsInHierarchy = (mesh) => { + if (!mesh) return; + + const meshes = [mesh, ...(mesh.getChildMeshes?.(false) || [])]; + + meshes.forEach((currentMesh) => { + if (!currentMesh || cachedMaterialMeshes.has(currentMesh)) return; + + const material = currentMesh.material; + if (material) { + const cachedMaterial = cacheExistingMaterial(material); + if (cachedMaterial !== material) { + currentMesh.material = cachedMaterial; + } + } + + cachedMaterialMeshes.add(currentMesh); + }); +}; + const incrementCacheRef = (key) => { const entry = materialCache.get(key); if (entry) entry.refCount += 1; @@ -67,6 +138,8 @@ export function setFlockReference(ref) { flock.materialCacheKey = descriptorKey; flock.acquireCachedMaterial = incrementCacheRef; flock.registerCachedMaterial = registerCachedMaterial; + flock.cacheExistingMaterial = cacheExistingMaterial; + flock.cacheMaterialsInHierarchy = cacheMaterialsInHierarchy; } export const flockMaterial = { diff --git a/api/models.js b/api/models.js index 5d46f286..77df7711 100644 --- a/api/models.js +++ b/api/models.js @@ -278,6 +278,7 @@ export const flockModels = { // PATH A: Cache if (flock.modelCache[modelName]) { const mesh = flock.modelCache[modelName].clone(bKey); + flock.cacheMaterialsInHierarchy?.(mesh); finalizeMesh(mesh, meshName, groupName, bKey); resolveReady(mesh); return meshName; @@ -287,6 +288,7 @@ export const flockModels = { if (flock.modelsBeingLoaded[modelName]) { flock.modelsBeingLoaded[modelName].then(() => { const mesh = flock.modelCache[modelName].clone(bKey); + flock.cacheMaterialsInHierarchy?.(mesh); finalizeMesh(mesh, meshName, groupName, bKey); resolveReady(mesh); }); @@ -302,12 +304,14 @@ export const flockModels = { const root = container.meshes[0]; if (applyColor) flock.ensureStandardMaterial(root); + flock.cacheMaterialsInHierarchy?.(root); // Cache Template const template = root.clone(`${modelName}_template`); template.setEnabled(false); template.isPickable = false; template.getChildMeshes().forEach(c => c.setEnabled(false)); + flock.cacheMaterialsInHierarchy?.(template); flock.modelCache[modelName] = template; // Finalize the one currently in scene diff --git a/api/scene.js b/api/scene.js index 6ee03ea2..4a308b3f 100644 --- a/api/scene.js +++ b/api/scene.js @@ -578,15 +578,18 @@ export const flockScene = { // Detach material from the mesh currentMesh.material = null; + if (material.metadata?.cacheKey) { + flock.releaseMaterial(material); + return; + } + // Dispose material if not already disposed if (!disposedMaterials.has(material)) { disposedMaterials.add(material); const sharedMaterial = currentMesh.metadata?.sharedMaterial; const internalMaterial = material.metadata?.internal; - if (material.metadata?.cacheKey) { - flock.releaseMaterial(material); - } else if (sharedMaterial === false && internalMaterial === true) { + if (sharedMaterial === false && internalMaterial === true) { // Remove from scene.materials flock.scene.materials = flock.scene.materials.filter( (mat) => mat !== material, From a8dc6816e81e69641fcb353e034b0638f7f89b9b Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Sat, 27 Dec 2025 08:11:27 +0000 Subject: [PATCH 04/11] Add debug logging for material cache reuse --- api/material.js | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/api/material.js b/api/material.js index def24cd7..620e31e2 100644 --- a/api/material.js +++ b/api/material.js @@ -4,6 +4,10 @@ const materialCache = new Map(); const materialCacheKeys = new WeakMap(); const cachedMaterialMeshes = new WeakSet(); +const logMaterialCache = (...args) => { + if (flock?.materialsDebug) console.log("[MaterialCache]", ...args); +}; + const normaliseColorInput = (input) => { const asHex = (value) => { if (typeof value === "string") return flock.getColorFromString(value); @@ -55,6 +59,7 @@ const registerCachedMaterial = (material, key) => { material.metadata.cacheKey = key; materialCache.set(key, { material, refCount: 1 }); materialCacheKeys.set(material, key); + logMaterialCache("Registered material", { key, name: material.name }); }; const describeStandardMaterial = (material) => { @@ -90,20 +95,29 @@ const cacheExistingMaterial = (material) => { material.metadata = material.metadata || {}; material.metadata.sharedMaterial = true; incrementCacheRef(existingKey); + logMaterialCache("Reusing material by metadata", { key: existingKey }); return materialCache.get(existingKey).material; } const descriptor = describeStandardMaterial(material); - if (!descriptor) return material; + if (!descriptor) { + logMaterialCache("Skipped caching (unsupported type)", { + materialName: material?.name, + materialType: material?.getClassName?.(), + }); + return material; + } const key = descriptorKey(descriptor); const existing = incrementCacheRef(key); if (existing) { material.dispose?.(); + logMaterialCache("Reusing matching cached material", { key, descriptor }); return existing; } registerCachedMaterial(material, key); + logMaterialCache("Added new cached material", { key, descriptor }); return material; }; @@ -129,7 +143,10 @@ const cacheMaterialsInHierarchy = (mesh) => { const incrementCacheRef = (key) => { const entry = materialCache.get(key); - if (entry) entry.refCount += 1; + if (entry) { + entry.refCount += 1; + logMaterialCache("Increment ref", { key, refCount: entry.refCount }); + } return entry?.material ?? null; }; @@ -341,10 +358,16 @@ export const flockMaterial = { if (key && materialCache.has(key)) { const entry = materialCache.get(key); entry.refCount = Math.max(0, (entry.refCount || 0) - 1); + logMaterialCache("Release cached material", { + key, + refCount: entry.refCount, + name: material.name, + }); if (entry.refCount === 0) { materialCache.delete(key); materialCacheKeys.delete(material); + logMaterialCache("Disposing cached material", { key, name: material.name }); if (Array.isArray(flock.scene?.materials)) { flock.scene.materials = flock.scene.materials.filter( (mat) => mat !== material, From 14aa2970289579d22831ff00a253fdb228bcc5d1 Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Sat, 27 Dec 2025 08:14:49 +0000 Subject: [PATCH 05/11] Enable material cache debug toggle --- flock.js | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/flock.js b/flock.js index a3088fde..3c07f494 100644 --- a/flock.js +++ b/flock.js @@ -82,13 +82,48 @@ import { translate } from "./main/translation.js"; // Helper functions to make flock.BABYLON js easier to use in Flock console.log("Flock helpers loading"); +const parseBooleanFlag = (value) => { + if (value == null) return null; + const normalised = String(value).toLowerCase(); + if (["", "1", "true", "yes", "on"].includes(normalised)) return true; + if (["0", "false", "no", "off"].includes(normalised)) return false; + return null; +}; + +const resolveMaterialsDebugFlag = () => { + if (typeof window === "undefined") return false; + + try { + const params = new URLSearchParams(window.location.search || ""); + const overrideFlag = parseBooleanFlag(params.get("materialsDebug")); + const debugList = (params.get("debug") || "") + .split(/[ ,]/) + .map((value) => value.trim()) + .filter(Boolean); + if (overrideFlag !== null) { + if (overrideFlag) console.log("[MaterialCache] debug enabled (query)"); + return overrideFlag; + } + + const storedFlag = window.localStorage?.getItem("flockMaterialsDebug"); + const enabled = storedFlag === "true" || debugList.includes("materials"); + + if (enabled) console.log("[MaterialCache] debug enabled"); + return enabled; + } catch (error) { + console.warn("Unable to resolve materials debug flag", error); + } + + return false; +}; + export const flock = { blockDebug: false, callbackMode: true, separateAnimations: true, memoryDebug: false, memoryMonitorInterval: 5000, - materialsDebug: false, + materialsDebug: resolveMaterialsDebugFlag(), meshDebug: false, performanceOverlay: false, maxMeshes: 5000, From 7d52544537de51069ea12863fd6a74ceefc8724b Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Sat, 27 Dec 2025 08:21:48 +0000 Subject: [PATCH 06/11] Remove materials debug localStorage toggle --- flock.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/flock.js b/flock.js index 3c07f494..8812c6f2 100644 --- a/flock.js +++ b/flock.js @@ -105,8 +105,7 @@ const resolveMaterialsDebugFlag = () => { return overrideFlag; } - const storedFlag = window.localStorage?.getItem("flockMaterialsDebug"); - const enabled = storedFlag === "true" || debugList.includes("materials"); + const enabled = debugList.includes("materials"); if (enabled) console.log("[MaterialCache] debug enabled"); return enabled; From 00bb324052c0cef2778f031b379620c4223dcc2f Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Sat, 27 Dec 2025 08:21:55 +0000 Subject: [PATCH 07/11] Cache recolored materials in hierarchy --- api/material.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/api/material.js b/api/material.js index 620e31e2..7c47d209 100644 --- a/api/material.js +++ b/api/material.js @@ -828,6 +828,17 @@ export const flockMaterial = { if (!flock.characterNames.includes(mesh.metadata?.meshName)) { applyColorInOrder(mesh); + + materialToColorMap.forEach((_, mat) => { + const cached = flock.cacheExistingMaterial(mat); + if (cached && cached !== mat) { + // Replace the material on meshes that still reference the uncached instance + mesh.getChildMeshes(true)?.forEach((child) => { + if (child.material === mat) child.material = cached; + }); + if (mesh.material === mat) mesh.material = cached; + } + }); } else { const characterColors = { hair: colors[0], From c20d10fef15d57d0a76aee8186cca3edba7d5e63 Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Sat, 27 Dec 2025 08:24:59 +0000 Subject: [PATCH 08/11] Fix recolor cache when using shared materials --- api/material.js | 55 +++++++++++++++++++++++++++---------------------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/api/material.js b/api/material.js index 7c47d209..8f7a722b 100644 --- a/api/material.js +++ b/api/material.js @@ -561,29 +561,30 @@ export const flockMaterial = { // Iterate through all collected meshes allMeshes.forEach((currentMesh) => { - if (currentMesh.material && currentMesh.metadata?.sharedMaterial) { - const originalMaterial = currentMesh.material; - // Check if the material has already been cloned - if (!materialMapping.has(originalMaterial)) { - // Clone the material and store it in the mapping - if (flock.materialsDebug) - console.log( - ` Cloning material, ${originalMaterial}, of ${currentMesh.name}`, - ); - const clonedMaterial = cloneMaterial(originalMaterial); - clonedMaterial.metadata = { - ...(clonedMaterial.metadata || {}), - sharedMaterial: false, - cacheKey: undefined, - }; - materialMapping.set(originalMaterial, clonedMaterial); - } + const originalMaterial = currentMesh.material; + const isSharedMaterial = originalMaterial?.metadata?.sharedMaterial; + if (!originalMaterial || !isSharedMaterial) return; - // Assign the cloned material to the current mesh - currentMesh.material = materialMapping.get(originalMaterial); - flock.releaseMaterial(originalMaterial, { forceDispose: false }); - currentMesh.metadata.sharedMaterial = false; // Material is now unique to this hierarchy + // Check if the material has already been cloned + if (!materialMapping.has(originalMaterial)) { + // Clone the material and store it in the mapping + if (flock.materialsDebug) + console.log( + ` Cloning material, ${originalMaterial}, of ${currentMesh.name}`, + ); + const clonedMaterial = cloneMaterial(originalMaterial); + clonedMaterial.metadata = { + ...(clonedMaterial.metadata || {}), + sharedMaterial: false, + cacheKey: undefined, + }; + materialMapping.set(originalMaterial, clonedMaterial); } + + // Assign the cloned material to the current mesh + currentMesh.material = materialMapping.get(originalMaterial); + flock.releaseMaterial(originalMaterial, { forceDispose: false }); + currentMesh.metadata.sharedMaterial = false; // Material is now unique to this hierarchy }); }, ensureStandardMaterial(mesh) { @@ -756,11 +757,15 @@ export const flockMaterial = { return; } - if ( - mesh.metadata?.sharedMaterial && - !(mesh?.metadata?.clones && mesh.metadata?.clones?.length >= 1) - ) + const meshHasSharedMaterial = (targetMesh) => { + const mat = targetMesh.material; + return mat?.metadata?.sharedMaterial === true; + }; + + const childMeshes = mesh.getChildMeshes ? mesh.getChildMeshes(true) : []; + if (meshHasSharedMaterial(mesh) || childMeshes.some(meshHasSharedMaterial)) { flock.ensureUniqueMaterial(mesh); + } // Ensure color is an array const colors = Array.isArray(color) ? color : [color]; From e1ce6ef0a6b8733941e4ff9add46cbe2369e024c Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Sat, 27 Dec 2025 08:27:59 +0000 Subject: [PATCH 09/11] Clone shared materials per mesh for recolor --- api/material.js | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/api/material.js b/api/material.js index 8f7a722b..f73320ec 100644 --- a/api/material.js +++ b/api/material.js @@ -556,34 +556,27 @@ export const flockMaterial = { // Collect all meshes in the hierarchy (root + descendants) const allMeshes = collectMeshes(mesh); - // Create a mapping of original materials to their clones - const materialMapping = new Map(); - // Iterate through all collected meshes allMeshes.forEach((currentMesh) => { const originalMaterial = currentMesh.material; const isSharedMaterial = originalMaterial?.metadata?.sharedMaterial; if (!originalMaterial || !isSharedMaterial) return; - // Check if the material has already been cloned - if (!materialMapping.has(originalMaterial)) { - // Clone the material and store it in the mapping - if (flock.materialsDebug) - console.log( - ` Cloning material, ${originalMaterial}, of ${currentMesh.name}`, - ); - const clonedMaterial = cloneMaterial(originalMaterial); - clonedMaterial.metadata = { - ...(clonedMaterial.metadata || {}), - sharedMaterial: false, - cacheKey: undefined, - }; - materialMapping.set(originalMaterial, clonedMaterial); - } + if (flock.materialsDebug) + console.log( + ` Cloning material, ${originalMaterial}, of ${currentMesh.name}`, + ); + const clonedMaterial = cloneMaterial(originalMaterial); + clonedMaterial.metadata = { + ...(clonedMaterial.metadata || {}), + sharedMaterial: false, + cacheKey: undefined, + }; // Assign the cloned material to the current mesh - currentMesh.material = materialMapping.get(originalMaterial); + currentMesh.material = clonedMaterial; flock.releaseMaterial(originalMaterial, { forceDispose: false }); + currentMesh.metadata = currentMesh.metadata || {}; currentMesh.metadata.sharedMaterial = false; // Material is now unique to this hierarchy }); }, From 0ef9723cc4f0b0aa79d75f1ff16d9f06f06a16bc Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Sat, 27 Dec 2025 08:45:51 +0000 Subject: [PATCH 10/11] Keep recolored materials uncached --- api/material.js | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/api/material.js b/api/material.js index f73320ec..78049fae 100644 --- a/api/material.js +++ b/api/material.js @@ -828,14 +828,9 @@ export const flockMaterial = { applyColorInOrder(mesh); materialToColorMap.forEach((_, mat) => { - const cached = flock.cacheExistingMaterial(mat); - if (cached && cached !== mat) { - // Replace the material on meshes that still reference the uncached instance - mesh.getChildMeshes(true)?.forEach((child) => { - if (child.material === mat) child.material = cached; - }); - if (mesh.material === mat) mesh.material = cached; - } + mat.metadata = mat.metadata || {}; + mat.metadata.sharedMaterial = false; + mat.metadata.cacheKey = undefined; }); } else { const characterColors = { From 4f5ca696f3dd9755a85abbece893e2900d2af3a9 Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Sat, 27 Dec 2025 08:45:57 +0000 Subject: [PATCH 11/11] Share recolored recolor cache --- api/material.js | 49 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/api/material.js b/api/material.js index 78049fae..459cf229 100644 --- a/api/material.js +++ b/api/material.js @@ -770,6 +770,46 @@ export const flockMaterial = { // Map to keep track of materials and their assigned colours and indices const materialToColorMap = new Map(); + const recordMaterialUse = (material, meshPart) => { + const existing = materialToColorMap.get(material) || { meshes: [] }; + existing.meshes.push(meshPart); + materialToColorMap.set(material, existing); + }; + + const cacheRecoloredMaterials = () => { + materialToColorMap.forEach((info, material) => { + if (!material) return; + + const descriptor = describeStandardMaterial(material); + if (!descriptor) return; + + const key = descriptorKey(descriptor); + const existingEntry = materialCache.get(key); + + if (existingEntry) { + info.meshes.forEach(() => incrementCacheRef(key)); + const cachedMaterial = existingEntry.material; + if (cachedMaterial !== material) { + logMaterialCache("Reusing recolored cached material", { key, descriptor }); + material.dispose?.(); + } + info.meshes.forEach((meshPart) => { + if (meshPart.material !== cachedMaterial) { + meshPart.material = cachedMaterial; + } + }); + return; + } + + registerCachedMaterial(material, key); + const entry = materialCache.get(key); + if (entry) { + entry.refCount += Math.max(0, info.meshes.length - 1); + } + logMaterialCache("Added recolored cached material", { key, descriptor }); + }); + }; + function applyColorInOrder(part) { if (part.material) { // Check if the material is already processed @@ -792,6 +832,7 @@ export const flockMaterial = { materialToColorMap.set(part.material, { hexColor, index: currentIndex, + meshes: [part], }); // Set metadata on this mesh with its colour index @@ -809,6 +850,7 @@ export const flockMaterial = { part.metadata = {}; } + recordMaterialUse(part.material, part); if (part.metadata.materialIndex === undefined) { part.metadata.materialIndex = colorIndex; } @@ -826,12 +868,7 @@ export const flockMaterial = { if (!flock.characterNames.includes(mesh.metadata?.meshName)) { applyColorInOrder(mesh); - - materialToColorMap.forEach((_, mat) => { - mat.metadata = mat.metadata || {}; - mat.metadata.sharedMaterial = false; - mat.metadata.cacheKey = undefined; - }); + cacheRecoloredMaterials(); } else { const characterColors = { hair: colors[0],