From c85f68f23324f54277ef63048ff5dddd65da075e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 21:44:42 +0000 Subject: [PATCH 1/4] Fix material handling for multicolour gradients and caching - Add missing gradient_material block generator - Fix makeTargetCacheKey to match getOrCreateMaterial format (alpha toFixed(2) and glow key), so the cache-hit early-exit in applyMaterialToHierarchy now fires correctly - Extract getMaterialParamsFromMesh helper that reads colour from metadata.cacheKey (preserving gradient arrays) with fallback to diffuseColor/albedoColor for raw GLTF materials; use it in glowMesh, clearEffects and setAlpha, replacing duplicated logic that was losing gradient colours by falling back to white - Add fog uniforms and fog-aware registration to createMultiColorGradientMaterial so multicolour gradients respond to scene fog consistently with other shader materials - Add tests covering gradient preservation through glow, clearEffects, setAlpha and cache-hit behaviour https://claude.ai/code/session_01Jckn16VqMzdP7SYXerpW9y --- api/material.js | 153 +++++++++++++++++------------- generators/generators-material.js | 20 ++++ tests/materials.test.js | 111 ++++++++++++++++++++++ 3 files changed, 218 insertions(+), 66 deletions(-) diff --git a/api/material.js b/api/material.js index f68fd4d6b..2b8395eb5 100644 --- a/api/material.js +++ b/api/material.js @@ -152,29 +152,44 @@ export const flockMaterial = { }); }); }, + getMaterialParamsFromMesh(mesh) { + const mat = mesh.material; + if (!mat) return null; + + if (mat.metadata?.cacheKey) { + const parts = mat.metadata.cacheKey.split("_"); + const colorPart = parts[1]; + const color = colorPart.includes("-") + ? colorPart.split("-") + : colorPart; + return { + color, + materialName: parts[3] || "none.png", + alpha: parseFloat(parts[2]) || 1, + glow: parts[4] === "glow", + }; + } + + const matColor = mat.diffuseColor || mat.albedoColor; + return { + color: matColor ? "#" + matColor.toHexString().slice(1) : "#ffffff", + materialName: "none.png", + alpha: mat.alpha ?? 1, + glow: mesh.metadata?.glow ?? false, + }; + }, glowMesh(mesh, glowColor = null) { const applyGlow = (m) => { m.metadata = m.metadata || {}; m.metadata.glow = true; if (m.material) { - const currentMat = m.material; - const color = glowColor - ? flock.getColorFromString(glowColor) - : currentMat.diffuseColor - ? "#" + currentMat.diffuseColor.toHexString().slice(1) - : currentMat.albedoColor - ? "#" + currentMat.albedoColor.toHexString().slice(1) - : "#ffffff"; - + const params = flock.getMaterialParamsFromMesh(m); const materialParams = { - color: color, - materialName: - currentMat.metadata?.cacheKey?.split("_")[3] || "none.png", - alpha: currentMat.alpha ?? 1, + ...params, + color: glowColor ? flock.getColorFromString(glowColor) : params.color, glow: true, }; - flock.setMaterialWithCleanup(m, materialParams); } }; @@ -192,34 +207,10 @@ export const flockMaterial = { ); allMeshes.forEach((nextMesh) => { - const oldMat = nextMesh.material; - if (!oldMat) return; - - let color, texPart, glowFlag; - - if (oldMat.metadata?.cacheKey) { - const parts = oldMat.metadata.cacheKey.split("_"); - const colorPart = parts[1]; - texPart = parts[3]; - glowFlag = parts[4] === "glow"; - color = colorPart.includes("-") ? colorPart.split("-") : colorPart; - } else { - // Raw GLTF material (e.g. character with no managed material yet) - // Extract color from the material directly - const matColor = oldMat.diffuseColor || oldMat.albedoColor; - color = matColor - ? "#" + matColor.toHexString().slice(1) - : "#ffffff"; - texPart = "none.png"; - glowFlag = nextMesh.metadata?.glow ?? false; - } + if (!nextMesh.material) return; - const materialParams = { - color: color, - materialName: texPart, - alpha: value, - glow: glowFlag, - }; + const params = flock.getMaterialParamsFromMesh(nextMesh); + const materialParams = { ...params, alpha: value }; flock.setMaterialWithCleanup(nextMesh, materialParams); @@ -240,20 +231,8 @@ export const flockMaterial = { console.log(`Clear effects from ${meshName}:`); const removeEffects = (targetMesh) => { if (targetMesh.material) { - const currentMat = targetMesh.material; - const color = currentMat.diffuseColor - ? "#" + currentMat.diffuseColor.toHexString().slice(1) - : currentMat.albedoColor - ? "#" + currentMat.albedoColor.toHexString().slice(1) - : "#ffffff"; - - const materialParams = { - color: color, - materialName: - currentMat.metadata?.cacheKey?.split("_")[3] || "none.png", - alpha: 1, - glow: false, - }; + const params = flock.getMaterialParamsFromMesh(targetMesh); + const materialParams = { ...params, alpha: 1, glow: false }; flock.setMaterialWithCleanup(targetMesh, materialParams); @@ -968,30 +947,45 @@ export const flockMaterial = { return material; }, createMultiColorGradientMaterial(name, colors) { - // Register shaders once under dedicated keys to avoid collision with createMultiGradientShaderMaterial - if (!flock.BABYLON.Effect.ShadersStore["multiColorGradientVertexShader"]) { - flock.BABYLON.Effect.ShadersStore["multiColorGradientVertexShader"] = ` + if ( + !flock.BABYLON.Effect.ShadersStore["multiColorGradientFogVertexShader"] + ) { + flock.BABYLON.Effect.ShadersStore["multiColorGradientFogVertexShader"] = ` precision highp float; attribute vec3 position; uniform mat4 worldViewProjection; + uniform mat4 world; + uniform mat4 view; uniform vec2 minMax; varying float vGradient; + varying vec3 vFogPosition; void main(void) { + vec4 worldPosition = world * vec4(position, 1.0); + vec4 viewPosition = view * worldPosition; gl_Position = worldViewProjection * vec4(position, 1.0); vGradient = (position.y - minMax.x) / (minMax.y - minMax.x); + vFogPosition = viewPosition.xyz; } `; } if ( - !flock.BABYLON.Effect.ShadersStore["multiColorGradientFragmentShader"] + !flock.BABYLON.Effect.ShadersStore["multiColorGradientFogFragmentShader"] ) { - flock.BABYLON.Effect.ShadersStore["multiColorGradientFragmentShader"] = ` + flock.BABYLON.Effect.ShadersStore[ + "multiColorGradientFogFragmentShader" + ] = ` precision highp float; varying float vGradient; + varying vec3 vFogPosition; uniform int colorCount; uniform vec3 colors[16]; uniform float alpha; + uniform vec3 fogColor; + uniform float fogDensity; + uniform float fogStart; + uniform float fogEnd; + uniform int fogMode; void main(void) { float t = clamp(vGradient, 0.0, 1.0); @@ -1013,7 +1007,19 @@ export const flockMaterial = { vec3 color2 = colors[segment + 1]; vec3 finalColor = mix(color1, color2, localT); - gl_FragColor = vec4(finalColor, alpha); + float fogDistance = length(vFogPosition); + float fogFactor = 1.0; + if (fogMode == 1) { + fogFactor = exp(-fogDensity * fogDistance); + } else if (fogMode == 2) { + fogFactor = exp(-pow(fogDensity * fogDistance, 2.0)); + } else if (fogMode == 3) { + fogFactor = (fogEnd - fogDistance) / max(0.0001, fogEnd - fogStart); + } + fogFactor = clamp(fogFactor, 0.0, 1.0); + + vec3 foggedColor = mix(fogColor, finalColor, fogFactor); + gl_FragColor = vec4(foggedColor, alpha); } `; } @@ -1022,17 +1028,24 @@ export const flockMaterial = { name, flock.scene, { - vertex: "multiColorGradient", - fragment: "multiColorGradient", + vertex: "multiColorGradientFog", + fragment: "multiColorGradientFog", }, { attributes: ["position"], uniforms: [ "worldViewProjection", + "world", + "view", "colorCount", "colors", "alpha", "minMax", + "fogColor", + "fogDensity", + "fogStart", + "fogEnd", + "fogMode", ], }, ); @@ -1057,6 +1070,9 @@ export const flockMaterial = { shaderMaterial.setFloat("alpha", 1.0); shaderMaterial.setVector2("minMax", new flock.BABYLON.Vector2(-1, 1)); + flock.registerFogAwareShaderMaterial(shaderMaterial); + flock.updateFogUniformsForShaderMaterial(shaderMaterial); + return shaderMaterial; }, // Create shader material for color replacement @@ -1554,9 +1570,14 @@ export const flockMaterial = { ? rawColor.join("-") : flock.getColorFromString(rawColor) || "#ffffff"; const texName = String(getTexName(v)); - const alpha = getAlpha(v); - - return `mat_${colorKey.toLowerCase()}_${alpha}_${texName}`.toLowerCase(); + const alphaKey = parseFloat(getAlpha(v)).toFixed(2); + const glow = + typeof v === "object" && v !== null && !Array.isArray(v) + ? (v.glow ?? false) + : false; + const glowKey = glow ? "glow" : "noglow"; + + return `mat_${colorKey}_${alphaKey}_${texName}_${glowKey}`.toLowerCase(); }; const applyOne = (m, v, index) => { diff --git a/generators/generators-material.js b/generators/generators-material.js index aeee270e9..1925dad6a 100644 --- a/generators/generators-material.js +++ b/generators/generators-material.js @@ -147,4 +147,24 @@ export function registerMaterialGenerators(javascriptGenerator) { const code = `"${colour}"`; return [code, javascriptGenerator.ORDER_ATOMIC]; }; + + // Gradient material -------------------------------------------- + javascriptGenerator.forBlock["gradient_material"] = function (block) { + const color = + javascriptGenerator.valueToCode( + block, + "COLOR", + javascriptGenerator.ORDER_ATOMIC, + ) || '"#ffffff"'; + + const alpha = + javascriptGenerator.valueToCode( + block, + "ALPHA", + javascriptGenerator.ORDER_ATOMIC, + ) || "1"; + + const code = `{ color: ${color}, materialName: "none.png", alpha: ${alpha} }`; + return [code, javascriptGenerator.ORDER_ATOMIC]; + }; } diff --git a/tests/materials.test.js b/tests/materials.test.js index 2a6f24d4a..60b5ff740 100644 --- a/tests/materials.test.js +++ b/tests/materials.test.js @@ -999,4 +999,115 @@ export function runMaterialsTests(flock) { }); }); }); + + describe("gradient material preservation @materials", function () { + const boxIds = []; + + beforeEach(async function () { + flock.scene ??= {}; + }); + + afterEach(function () { + boxIds.forEach((id) => flock.dispose(id)); + boxIds.length = 0; + }); + + async function createGradientBox(id, colors) { + await flock.createBox(id, { + width: 1, + height: 2, + depth: 1, + color: { color: colors, materialName: "none.png", alpha: 1 }, + position: [0, 0, 0], + }); + } + + function getTarget(id) { + const mesh = flock.scene.getMeshByName(id); + const children = mesh + .getDescendants(false) + .filter((n) => n.getTotalVertices && n.getTotalVertices() > 0); + return children.length ? children[0] : mesh; + } + + it("should preserve 2-colour gradient after glow", async function () { + const id = "gradGlow2"; + await createGradientBox(id, ["#ff0000", "#0000ff"]); + boxIds.push(id); + + await flock.glow(id); + + const target = getTarget(id); + expect(target.material.getClassName()).to.equal("GradientMaterial"); + }); + + it("should preserve 3+ colour gradient after glow", async function () { + const id = "gradGlow3"; + await createGradientBox(id, ["#ff0000", "#00ff00", "#0000ff"]); + boxIds.push(id); + + await flock.glow(id); + + const target = getTarget(id); + expect(target.material.getClassName()).to.equal("ShaderMaterial"); + }); + + it("should preserve 2-colour gradient after clearEffects", async function () { + const id = "gradClear2"; + await createGradientBox(id, ["#ff0000", "#0000ff"]); + boxIds.push(id); + + await flock.glow(id); + await flock.clearEffects(id); + + const target = getTarget(id); + expect(target.material.getClassName()).to.equal("GradientMaterial"); + }); + + it("should preserve 3+ colour gradient after clearEffects", async function () { + const id = "gradClear3"; + await createGradientBox(id, ["#ff0000", "#00ff00", "#0000ff"]); + boxIds.push(id); + + await flock.glow(id); + await flock.clearEffects(id); + + const target = getTarget(id); + expect(target.material.getClassName()).to.equal("ShaderMaterial"); + }); + + it("should preserve 3+ colour gradient after setAlpha", async function () { + const id = "gradAlpha3"; + await createGradientBox(id, ["#ff0000", "#00ff00", "#0000ff"]); + boxIds.push(id); + + await flock.setAlpha(id, { value: 0.5 }); + + const target = getTarget(id); + expect(target.material.getClassName()).to.equal("ShaderMaterial"); + expect(target.material.alpha).to.be.closeTo(0.5, 0.01); + }); + + it("should hit cache when applying the same material twice via applyMaterialToHierarchy", async function () { + const id = "gradCacheHit"; + await flock.createBox(id, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + boxIds.push(id); + + const mesh = flock.scene.getMeshByName(id); + const descriptor = { color: "#aa00ff", materialName: "none.png", alpha: 1 }; + + flock.applyMaterialToHierarchy(mesh, descriptor); + const matAfterFirst = mesh.material; + + flock.applyMaterialToHierarchy(mesh, descriptor); + const matAfterSecond = mesh.material; + + expect(matAfterFirst).to.equal(matAfterSecond); + }); + }); } From a6a5dce62d3c99d81c5a3047b479f06f677979ea Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 31 Mar 2026 05:12:20 +0000 Subject: [PATCH 2/4] Fix getMaterialParamsFromMesh cacheKey parsing and gradient glow colour - Parse materialName by taking all parts between index 3 and the glow suffix, joined with "_", so texture names containing underscores are preserved - Use Number.isFinite check instead of || 1 so a cached alpha of 0.00 is not coerced to 1 - Detect glow/noglow suffix by checking the last part, with fallback to mesh.metadata.glow for older unmanaged materials - In fallback branch, read texture name from mat.diffuseTexture/ albedoTexture instead of hardcoding "none.png" so unmanaged textured GLTF materials preserve their texture on glow/setAlpha/clearEffects - In glowMesh, only replace params.color with the explicit glowColor when params.color is a scalar; gradient arrays are kept intact so createMaterial continues to produce a GradientMaterial/ShaderMaterial Addresses CodeRabbit review comments on PR #502. https://claude.ai/code/session_01Jckn16VqMzdP7SYXerpW9y --- api/material.js | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/api/material.js b/api/material.js index 2b8395eb5..d8c7cb9ed 100644 --- a/api/material.js +++ b/api/material.js @@ -158,22 +158,31 @@ export const flockMaterial = { if (mat.metadata?.cacheKey) { const parts = mat.metadata.cacheKey.split("_"); + const lastPart = parts[parts.length - 1]; + const hasGlowPart = lastPart === "glow" || lastPart === "noglow"; const colorPart = parts[1]; const color = colorPart.includes("-") ? colorPart.split("-") : colorPart; + const parsedAlpha = parseFloat(parts[2]); return { color, - materialName: parts[3] || "none.png", - alpha: parseFloat(parts[2]) || 1, - glow: parts[4] === "glow", + materialName: + parts.slice(3, hasGlowPart ? -1 : parts.length).join("_") || + "none.png", + alpha: Number.isFinite(parsedAlpha) ? parsedAlpha : (mat.alpha ?? 1), + glow: hasGlowPart ? lastPart === "glow" : (mesh.metadata?.glow ?? false), }; } const matColor = mat.diffuseColor || mat.albedoColor; + const textureName = + mat.diffuseTexture?.name?.split("/").pop() || + mat.albedoTexture?.name?.split("/").pop() || + "none.png"; return { color: matColor ? "#" + matColor.toHexString().slice(1) : "#ffffff", - materialName: "none.png", + materialName: textureName, alpha: mat.alpha ?? 1, glow: mesh.metadata?.glow ?? false, }; @@ -187,7 +196,10 @@ export const flockMaterial = { const params = flock.getMaterialParamsFromMesh(m); const materialParams = { ...params, - color: glowColor ? flock.getColorFromString(glowColor) : params.color, + color: + glowColor && !Array.isArray(params.color) + ? flock.getColorFromString(glowColor) + : params.color, glow: true, }; flock.setMaterialWithCleanup(m, materialParams); From aac9bea2ce5aec664896db8a94a6cd5dec166250 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 31 Mar 2026 06:17:10 +0000 Subject: [PATCH 3/4] Fix glow for gradient materials via customEmissiveColorSelector GradientMaterial and ShaderMaterial have no emissiveColor property so Babylon's GlowLayer never produced a visible effect on gradient meshes. Install customEmissiveColorSelector on the GlowLayer that reads metadata.glowColor per-mesh, bypassing the material entirely. Store the resolved glow colour on metadata.glowColor in glowMesh (using the first array colour for gradients, or the explicit glowColor arg for scalar materials). Clear metadata.glowColor in clearEffects. This makes glow work consistently for all material types: Standard, GradientMaterial (2-colour), and ShaderMaterial (3+ colour). Update tests to assert glowColor metadata is set after glow and cleared after clearEffects. https://claude.ai/code/session_01Jckn16VqMzdP7SYXerpW9y --- api/material.js | 32 ++++++++++++++++++++++++++++---- tests/materials.test.js | 3 +++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/api/material.js b/api/material.js index d8c7cb9ed..d6f273258 100644 --- a/api/material.js +++ b/api/material.js @@ -138,6 +138,20 @@ export const flockMaterial = { if (flock.sky) { flock.glowLayer.addExcludedMesh(flock.sky); } + flock.glowLayer.customEmissiveColorSelector = ( + mesh, + _subMesh, + _material, + result, + ) => { + const glowColor = mesh.metadata?.glowColor; + if (glowColor) { + const c = flock.BABYLON.Color3.FromHexString(glowColor); + result.set(c.r, c.g, c.b, 1); + } else { + result.set(0, 0, 0, 0); + } + }; } return new Promise((resolve) => { @@ -192,14 +206,23 @@ export const flockMaterial = { m.metadata = m.metadata || {}; m.metadata.glow = true; - if (m.material) { - const params = flock.getMaterialParamsFromMesh(m); + const params = m.material ? flock.getMaterialParamsFromMesh(m) : null; + const baseColor = params?.color; + m.metadata.glowColor = glowColor + ? flock.getColorFromString(glowColor) + : Array.isArray(baseColor) + ? flock.getColorFromString(baseColor[0]) + : baseColor + ? flock.getColorFromString(baseColor) + : "#ffffff"; + + if (params) { const materialParams = { ...params, color: - glowColor && !Array.isArray(params.color) + glowColor && !Array.isArray(baseColor) ? flock.getColorFromString(glowColor) - : params.color, + : baseColor, glow: true, }; flock.setMaterialWithCleanup(m, materialParams); @@ -256,6 +279,7 @@ export const flockMaterial = { targetMesh.metadata = targetMesh.metadata || {}; targetMesh.metadata.glow = false; + delete targetMesh.metadata.glowColor; if (flock.glowLayer) { const anyGlowing = flock.scene.meshes.some( diff --git a/tests/materials.test.js b/tests/materials.test.js index 60b5ff740..964721193 100644 --- a/tests/materials.test.js +++ b/tests/materials.test.js @@ -1039,6 +1039,7 @@ export function runMaterialsTests(flock) { const target = getTarget(id); expect(target.material.getClassName()).to.equal("GradientMaterial"); + expect(target.metadata.glowColor).to.equal("#ff0000"); }); it("should preserve 3+ colour gradient after glow", async function () { @@ -1050,6 +1051,7 @@ export function runMaterialsTests(flock) { const target = getTarget(id); expect(target.material.getClassName()).to.equal("ShaderMaterial"); + expect(target.metadata.glowColor).to.equal("#ff0000"); }); it("should preserve 2-colour gradient after clearEffects", async function () { @@ -1062,6 +1064,7 @@ export function runMaterialsTests(flock) { const target = getTarget(id); expect(target.material.getClassName()).to.equal("GradientMaterial"); + expect(target.metadata.glowColor).to.be.undefined; }); it("should preserve 3+ colour gradient after clearEffects", async function () { From c6106856e89bc386c03c22aa1dde338cd6a4510e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 31 Mar 2026 06:31:17 +0000 Subject: [PATCH 4/4] Preserve original texture name case in material metadata The cacheKey is lowercased before storage, so deserialising materialName from it produces a lowercased path. On case-sensitive hosts (Linux, PWA service worker cache) this breaks asset lookup. Store texName with its original case in metadata.texName when the material is created in getOrCreateMaterial, and prefer that in getMaterialParamsFromMesh over the cacheKey-derived string. Keeps the cacheKey-split as a fallback for any pre-existing materials that lack the field. Addresses CodeRabbit review comment (offline PWA behaviour). https://claude.ai/code/session_01Jckn16VqMzdP7SYXerpW9y --- api/material.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/material.js b/api/material.js index d6f273258..cb9642590 100644 --- a/api/material.js +++ b/api/material.js @@ -182,6 +182,7 @@ export const flockMaterial = { return { color, materialName: + mat.metadata.texName || parts.slice(3, hasGlowPart ? -1 : parts.length).join("_") || "none.png", alpha: Number.isFinite(parsedAlpha) ? parsedAlpha : (mat.alpha ?? 1), @@ -1547,6 +1548,7 @@ export const flockMaterial = { if (!newMat.metadata) newMat.metadata = {}; newMat.metadata.cacheKey = cacheKey; newMat.metadata.isManaged = true; + newMat.metadata.texName = texName; if (finalAlpha < 1) { newMat.transparencyMode = flock.BABYLON.Material.MATERIAL_ALPHABLEND;