diff --git a/src/diagram/diagramObject.ts b/src/diagram/diagramObject.ts index 0e1c562..237fa85 100644 --- a/src/diagram/diagramObject.ts +++ b/src/diagram/diagramObject.ts @@ -468,15 +468,20 @@ export class DiagramObject { // Get or create material const material = curve.material as StandardMaterial; if (material) { - // Dispose old texture if it exists - if (material.emissiveTexture) { - AnimatedLineTexture.DisposeTexture(material.emissiveTexture); - } - // Create new colored texture using the closest toolbox color - const coloredTexture = AnimatedLineTexture.CreateColoredTexture(closestColor); - material.emissiveTexture = coloredTexture; - material.opacityTexture = coloredTexture; + // Check if we need to update the texture color + // Don't dispose cached textures - they're shared across connections! + const currentTextureName = material.emissiveTexture?.name || ''; + const needsColorUpdate = !material.emissiveTexture || + !currentTextureName.endsWith(closestColor); + + if (needsColorUpdate) { + // Get cached texture for the new color (creates if needed) + const coloredTexture = AnimatedLineTexture.CreateColoredTexture(closestColor); + material.emissiveTexture = coloredTexture; + material.opacityTexture = coloredTexture; + } + // If color matches, keep existing texture reference (already correct) } } diff --git a/src/diagram/functions/buildMeshFromDiagramEntity.ts b/src/diagram/functions/buildMeshFromDiagramEntity.ts index 60363f1..696683a 100644 --- a/src/diagram/functions/buildMeshFromDiagramEntity.ts +++ b/src/diagram/functions/buildMeshFromDiagramEntity.ts @@ -82,6 +82,7 @@ function createNewInstanceIfNecessary(entity: DiagramEntity, scene: Scene): Abst material.emissiveTexture = AnimatedLineTexture.Texture(); material.opacityTexture = AnimatedLineTexture.Texture(); material.disableLighting = true; + material.metadata = { isConnection: true, preserveTextures: true }; // Preserve animated arrow textures newMesh.setEnabled(false); break; case DiagramTemplates.BOX: @@ -174,6 +175,7 @@ function buildImage(entity: DiagramEntity, scene: Scene): AbstractMesh { logger.debug("buildImage: entity is image"); const plane = MeshBuilder.CreatePlane(entity.id, {size: 1}, scene); const material = new StandardMaterial("planeMaterial", scene); + material.metadata = { isUI: true }; // Mark as UI to prevent rendering mode modifications const image = new Image(); image.src = entity.image; material.emissiveTexture = new Texture(entity.image, scene); diff --git a/src/diagram/functions/createLabel.ts b/src/diagram/functions/createLabel.ts index 001394c..18916ee 100644 --- a/src/diagram/functions/createLabel.ts +++ b/src/diagram/functions/createLabel.ts @@ -36,7 +36,7 @@ function createMaterial(dynamicTexture: DynamicTexture): Material { mat.backFaceCulling = true; mat.emissiveTexture = dynamicTexture; mat.diffuseTexture = dynamicTexture; - mat.metadata = {exportable: true}; + mat.metadata = { exportable: true, isUI: true }; // Mark as UI to prevent rendering mode modifications //mat.freeze(); return mat; diff --git a/src/gizmos/ResizeGizmo/ResizeGizmo.ts b/src/gizmos/ResizeGizmo/ResizeGizmo.ts index f0aa261..14cd4e2 100644 --- a/src/gizmos/ResizeGizmo/ResizeGizmo.ts +++ b/src/gizmos/ResizeGizmo/ResizeGizmo.ts @@ -99,6 +99,7 @@ export class ResizeGizmo { */ private createMaterial(): void { this._handleMaterial = new StandardMaterial('resizeGizmoMaterial', this._utilityLayer.utilityLayerScene); + this._handleMaterial.metadata = { isUI: true }; // Mark as UI to prevent rendering mode modifications this._handleMaterial.diffuseColor = Color3.Blue(); this._handleMaterial.emissiveColor = Color3.Blue().scale(0.3); } diff --git a/src/menus/vrConfigPanel.ts b/src/menus/vrConfigPanel.ts index 095ed44..07aace1 100644 --- a/src/menus/vrConfigPanel.ts +++ b/src/menus/vrConfigPanel.ts @@ -187,6 +187,7 @@ export class VRConfigPanel { // Create material for panel backing const material = new StandardMaterial("vrConfigPanelMaterial", this._scene); + material.metadata = { isUI: true }; // Mark as UI to prevent rendering mode modifications material.diffuseColor = new Color3(0.1, 0.1, 0.15); // Dark blue-gray material.specularColor = new Color3(0.1, 0.1, 0.1); this._panelMesh.material = material; diff --git a/src/objects/Button.ts b/src/objects/Button.ts index 246d0cc..aa9d268 100644 --- a/src/objects/Button.ts +++ b/src/objects/Button.ts @@ -102,7 +102,12 @@ export class Button { } private buildMaterial(): StandardMaterial { - const mat = new StandardMaterial('buttonMat', this._scene); + // Use unique material name per button to prevent material sharing bugs + const mat = new StandardMaterial(`buttonMat-${this._mesh.id}`, this._scene); + + // Mark as UI material to prevent lightmap/rendering mode modifications + mat.metadata = { isUI: true }; + //mat.diffuseColor.set(.5, .5, .5); mat.backFaceCulling = false; this._textures.set(states.NORMAL, this.drawText(this._mesh.name, this._color, this._background)); diff --git a/src/objects/handle.ts b/src/objects/handle.ts index 1315656..c15420d 100644 --- a/src/objects/handle.ts +++ b/src/objects/handle.ts @@ -45,6 +45,7 @@ export class Handle { //button.transform.scaling.set(.1,.1,.1); const texture = this.drawText(this._label, Color3.White(), Color3.Black()); const material = new StandardMaterial('handleMaterial', scene); + material.metadata = { isUI: true }; // Mark as UI to prevent rendering mode modifications material.emissiveTexture = texture; material.opacityTexture = texture; material.disableLighting = true; diff --git a/src/objects/roundButton.ts b/src/objects/roundButton.ts index b162d26..41c6fd3 100644 --- a/src/objects/roundButton.ts +++ b/src/objects/roundButton.ts @@ -23,6 +23,7 @@ export class RoundButton { height: 256 }, this.parent.getScene()); const descMaterial = new StandardMaterial('button_desc_' + label) + descMaterial.metadata = { isUI: true }; // Mark as UI to prevent rendering mode modifications descriptionPlane.material = descMaterial; descMaterial.diffuseTexture = descTexture; descTexture.drawText(description, null, null, 'bold 64px Arial', @@ -30,6 +31,7 @@ export class RoundButton { const texture = new DynamicTexture('texture_' + label, {width: 256, height: 256}, this.parent.getScene()); const material = new StandardMaterial('button_' + label) + material.metadata = { isUI: true }; // Mark as UI to prevent rendering mode modifications button.material = material; material.diffuseTexture = texture; texture.drawText(label, null, null, 'bold 128px Arial', diff --git a/src/objects/spinner.ts b/src/objects/spinner.ts index 2e43347..7a4a830 100644 --- a/src/objects/spinner.ts +++ b/src/objects/spinner.ts @@ -50,6 +50,7 @@ export class Spinner { } const spinner: AbstractMesh = MeshBuilder.CreateSphere("spinner", {diameter: .5}, this._scene); const material = new StandardMaterial("spinner", this._scene); + material.metadata = { isUI: true }; // Mark as UI to prevent rendering mode modifications const text = new DynamicTexture("spinner", {width: 1024, height: 1024}, this._scene, false); text.drawText("Please Wait", 250, 500, "bold 150px Segoe UI", "white", "transparent", true, true); spinner.rotation.z = Math.PI; diff --git a/src/toolbox/toolbox.ts b/src/toolbox/toolbox.ts index ceb91fb..7e705e9 100644 --- a/src/toolbox/toolbox.ts +++ b/src/toolbox/toolbox.ts @@ -6,6 +6,7 @@ import {DefaultScene} from "../defaultScene"; import {Button} from "../objects/Button"; import {LightmapGenerator} from "../util/lightmapGenerator"; import {RenderingMode, RenderingModeLabels} from "../util/renderingMode"; +import {AnimatedLineTexture} from "../util/animatedLineTexture"; const colors: string[] = [ "#222222", "#8b4513", "#006400", "#778899", @@ -42,6 +43,9 @@ export class Toolbox { // Preload lightmaps for all toolbox colors for better first-render performance LightmapGenerator.preloadLightmaps(colors, this._scene); + // Preload connection textures for all toolbox colors to prevent first-connection stutter + AnimatedLineTexture.PreloadTextures(colors); + this.buildToolbox().then(() => { readyObservable.notifyObservers(true); this._logger.info('Toolbox built'); diff --git a/src/tutorial/introduction.ts b/src/tutorial/introduction.ts index 9ca6dbe..45aebd3 100644 --- a/src/tutorial/introduction.ts +++ b/src/tutorial/introduction.ts @@ -72,6 +72,7 @@ export class Introduction { const texture = new VideoTexture("video", vid, this._scene, true); const mesh = this.makeObject("video", position); const material = new StandardMaterial("video_material", this._scene); + material.metadata = { isUI: true }; // Mark as UI to prevent rendering mode modifications material.diffuseTexture = texture; material.diffuseColor = new Color3(1, 1, 1); material.emissiveColor = new Color3(1, 1, 1); diff --git a/src/util/animatedLineTexture.ts b/src/util/animatedLineTexture.ts index 08a3fed..ace9a8a 100644 --- a/src/util/animatedLineTexture.ts +++ b/src/util/animatedLineTexture.ts @@ -17,6 +17,8 @@ export class AnimatedLineTexture { private static _texture: Texture; private static _animatedTextures: Set = new Set(); private static _animationObserverAdded: boolean = false; + private static _coloredTextureCache: Map = new Map(); + private static _frameCounter: number = 0; public static Texture() { if (!AnimatedLineTexture._texture) { @@ -27,9 +29,14 @@ export class AnimatedLineTexture { if (!this._animationObserverAdded) { DefaultScene.Scene.onBeforeRenderObservable.add(() => { - this._animatedTextures.forEach(texture => { - texture.uOffset -= 0.01 * DefaultScene.Scene.getAnimationRatio(); - }); + // Update every other frame for performance (still smooth at 45fps in 90fps VR) + this._frameCounter++; + if (this._frameCounter % 2 === 0) { + this._animatedTextures.forEach(texture => { + // Double the offset to maintain same visual speed with half update frequency + texture.uOffset -= 0.02 * DefaultScene.Scene.getAnimationRatio(); + }); + } }); this._animationObserverAdded = true; } @@ -38,24 +45,38 @@ export class AnimatedLineTexture { } /** - * Creates a new texture with a specific color + * Creates a new texture with a specific color (cached for reuse) * @param hexColor - Hex color string (e.g., '#ff0000') - * @returns A new texture instance with the specified color + * @returns A cached texture instance with the specified color */ public static CreateColoredTexture(hexColor: string): Texture { + // Check cache first - reuse textures for same color + if (this._coloredTextureCache.has(hexColor)) { + return this._coloredTextureCache.get(hexColor)!; + } + + // Create new texture if not cached const texture = new Texture(createArrowSvg(hexColor), DefaultScene.Scene); texture.name = `connection-texture-${hexColor}`; texture.uScale = 30; + // Cache for future reuse + this._coloredTextureCache.set(hexColor, texture); + // Track this texture for animation updates this._animatedTextures.add(texture); // Ensure animation observer is set up if (!this._animationObserverAdded) { DefaultScene.Scene.onBeforeRenderObservable.add(() => { - this._animatedTextures.forEach(t => { - t.uOffset -= 0.01 * DefaultScene.Scene.getAnimationRatio(); - }); + // Update every other frame for performance (still smooth at 45fps in 90fps VR) + this._frameCounter++; + if (this._frameCounter % 2 === 0) { + this._animatedTextures.forEach(t => { + // Double the offset to maintain same visual speed with half update frequency + t.uOffset -= 0.02 * DefaultScene.Scene.getAnimationRatio(); + }); + } }); this._animationObserverAdded = true; } @@ -65,10 +86,60 @@ export class AnimatedLineTexture { /** * Removes a texture from the animation set when disposed + * WARNING: Do NOT call this on cached textures! Only for non-cached textures. + * Cached textures are shared across multiple connections. + * Use ClearCache() to dispose cached textures properly. * @param texture - The texture to stop animating */ public static DisposeTexture(texture: Texture): void { + // Safety check: prevent disposing cached textures (they're shared!) + for (const [color, cachedTexture] of this._coloredTextureCache.entries()) { + if (cachedTexture === texture) { + console.error( + `AnimatedLineTexture.DisposeTexture: Attempted to dispose cached texture ` + + `"${texture.name}" (color: ${color}). This will break texture sharing! ` + + `Cached textures should not be disposed individually. Use ClearCache() instead.` + ); + return; // Don't dispose - it's shared across multiple connections + } + } + + // Only dispose non-cached textures this._animatedTextures.delete(texture); texture.dispose(); } + + /** + * Preload textures for common colors to prevent first-render stutter + * @param colors - Array of hex color strings to preload + */ + public static PreloadTextures(colors: string[]): void { + colors.forEach(color => { + this.CreateColoredTexture(color); + }); + } + + /** + * Clear the texture cache and dispose all cached textures + * Use with caution - only call when no connections are using these textures + */ + public static ClearCache(): void { + this._coloredTextureCache.forEach((texture, color) => { + this._animatedTextures.delete(texture); + texture.dispose(); + }); + this._coloredTextureCache.clear(); + } + + /** + * Get cache statistics for debugging + * @returns Object with cache stats + */ + public static GetCacheStats(): { cachedColors: number; totalAnimatedTextures: number; colors: string[] } { + return { + cachedColors: this._coloredTextureCache.size, + totalAnimatedTextures: this._animatedTextures.size, + colors: Array.from(this._coloredTextureCache.keys()) + }; + } } \ No newline at end of file diff --git a/src/util/functions/updateTextNode.ts b/src/util/functions/updateTextNode.ts index db8e625..36ff69f 100644 --- a/src/util/functions/updateTextNode.ts +++ b/src/util/functions/updateTextNode.ts @@ -49,6 +49,7 @@ export function updateTextNode(mesh: AbstractMesh, text: string) { dynamicTexture.drawText(text, null, null, font, "#ffffff", "#000000", true); const mat = new StandardMaterial("mat", mesh.getScene()); + mat.metadata = { isUI: true }; // Mark as UI to prevent rendering mode modifications mat.diffuseColor = Color3.Black(); mat.disableLighting = true; mat.backFaceCulling = false; diff --git a/src/util/lightmapGenerator.ts b/src/util/lightmapGenerator.ts index 2af1128..a323e92 100644 --- a/src/util/lightmapGenerator.ts +++ b/src/util/lightmapGenerator.ts @@ -248,8 +248,11 @@ export class LightmapGenerator { scene.materials.forEach(material => { if (material instanceof StandardMaterial) { - // Skip UI materials (buttons, handles, and labels use emissiveTexture with text rendering) - if (material.name === 'buttonMat' || + // Skip UI materials and connections that should preserve their textures + // Check metadata first (most reliable), then fall back to name/id checks + if (material.metadata?.isUI === true || + material.metadata?.isConnection === true || // Preserve connection animated arrow textures + material.name.startsWith('buttonMat') || // Use startsWith for unique button names material.name === 'handleMaterial' || material.name === 'text-mat' || material.id.includes('button') || diff --git a/src/vrcore/initializeEngine.ts b/src/vrcore/initializeEngine.ts index 00efd45..7e6d2b6 100644 --- a/src/vrcore/initializeEngine.ts +++ b/src/vrcore/initializeEngine.ts @@ -31,6 +31,12 @@ export async function initializeEngine(params: EngineInitializerParams): Promise DefaultScene.Scene = scene; scene.ambientColor = new Color3(.1, .1, .1); + // Disable material dirty flagging for performance + // This prevents expensive material validation when animating texture offsets + // Safe for this app since we use unlit materials without complex dynamic properties + // + // scene.blockMaterialDirtyMechanism = true; + await params.onSceneReady(scene); engine.runRenderLoop(() => {