diff --git a/api/material.js b/api/material.js index f68fd4d6b..cb9642590 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) => { @@ -152,29 +166,66 @@ export const flockMaterial = { }); }); }, + getMaterialParamsFromMesh(mesh) { + const mat = mesh.material; + if (!mat) return null; + + 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: + mat.metadata.texName || + 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: textureName, + 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 = 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 = { - color: color, - materialName: - currentMat.metadata?.cacheKey?.split("_")[3] || "none.png", - alpha: currentMat.alpha ?? 1, + ...params, + color: + glowColor && !Array.isArray(baseColor) + ? flock.getColorFromString(glowColor) + : baseColor, glow: true, }; - flock.setMaterialWithCleanup(m, materialParams); } }; @@ -192,34 +243,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 +267,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); @@ -265,6 +280,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( @@ -968,30 +984,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 +1044,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 +1065,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 +1107,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 @@ -1495,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; @@ -1554,9 +1608,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..964721193 100644 --- a/tests/materials.test.js +++ b/tests/materials.test.js @@ -999,4 +999,118 @@ 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"); + expect(target.metadata.glowColor).to.equal("#ff0000"); + }); + + 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"); + expect(target.metadata.glowColor).to.equal("#ff0000"); + }); + + 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"); + expect(target.metadata.glowColor).to.be.undefined; + }); + + 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); + }); + }); }