Cycle Material On Click, not possible in AR mode

Hello, I hope you can help me. My click is registered in the browser, but not on iOS (AR). What could be causing this?

import { Material, Mesh, Object3D } from "three";
import { serializable } from "@needle-tools/engine";
import { Behaviour, GameObject } from "@needle-tools/engine";
import { IPointerClickHandler, PointerEventData } from "@needle-tools/engine";
import { isDevEnvironment } from "@needle-tools/engine";
import { ObjectRaycaster } from "@needle-tools/engine";
import type { UsdzBehaviour, BehaviorExtension } from "@needle-tools/engine";
import { USDObject, USDZExporterContext } from "@needle-tools/engine";
import { ActionBuilder, BehaviorModel, TriggerBuilder } from "@needle-tools/engine";

// Hilfsfunktion für Raycaster, da wir möglicherweise keinen direkten Zugriff auf die originale haben
function ensureRaycaster(obj: GameObject) {
    if (!obj) return;
    if (!obj.getComponentInParent(ObjectRaycaster)) {
        if (isDevEnvironment())
            console.debug("Raycaster on \"" + obj.name + "\" was automatically added, because no raycaster was found in the parent hierarchy.");
        obj.addComponent(ObjectRaycaster);
    }
}

/**
 * Wechselt zyklisch durch eine Liste von Materialien bei jedem Klick.
 * Funktioniert zur Laufzeit und in AR (USDZ).
 * @category Everywhere Actions
 * @group Components
 */
export class CycleMaterialOnClick extends Behaviour implements IPointerClickHandler, UsdzBehaviour {

    /**
     * Das Mesh-Objekt, dessen Material geändert werden soll.
     * Wenn nicht zugewiesen, wird versucht, das Material auf allen Meshes in diesem GameObject und seinen Kindern zu finden.
     */
    @serializable(Object3D)
    public targetObject?: Object3D;

    /**
     * Das ursprĂĽngliche Material, das ersetzt werden soll.
     */
    @serializable(Material)
    public originalMaterial?: Material;

    /**
     * Eine Liste von Materialien, durch die zyklisch gewechselt wird.
     */
    @serializable(Material)
    public materialCycle: Material[] = [];

    /**
     * Die Dauer des Ăśbergangs-Effekts in Sekunden (nur fĂĽr USDZ/AR).
     * @default 0.3
     */
    @serializable()
    public fadeDuration: number = 0.3;

    private currentIndex: number = -1;
    private meshesWithMaterial: Mesh[] = [];

    // --- AR / USDZ spezifische Variablen ---
    private selfModel?: USDObject;
    private variantModels: USDObject[][] = [];
    private static _startHiddenBehaviours: BehaviorModel[] = [];
    private static _isAfterCreateDocumentRun: boolean = false;


    start(): void {
        ensureRaycaster(this.gameObject);
        this.collectMeshes();

        if (isDevEnvironment() && this.meshesWithMaterial.length === 0) {
            console.warn(`CycleMaterialOnClick: Auf dem Zielobjekt "${this.targetObject?.name ?? this.gameObject.name}" wurde kein Mesh mit dem "Original Material" (${this.originalMaterial?.name}) gefunden.`);
        }
         if (isDevEnvironment() && this.materialCycle.length === 0) {
            console.warn(`CycleMaterialOnClick: Die "Material Cycle"-Liste ist leer. Bitte weisen Sie Materialien zu.`);
        }
    }

    onPointerClick(args: PointerEventData): void {
        if (this.materialCycle.length === 0) return;
        
        args.use();

        // Index hochzählen und am Ende der Liste wieder von vorne anfangen
        this.currentIndex = (this.currentIndex + 1) % this.materialCycle.length;

        const newMaterial = this.materialCycle[this.currentIndex];

        for (const mesh of this.meshesWithMaterial) {
            // Falls das Mesh mehrere Materialien hat
            if (Array.isArray(mesh.material)) {
                const matIndex = mesh.material.indexOf(this.originalMaterial!);
                if (matIndex !== -1) {
                    // Erstelle eine Kopie des Material-Arrays, um die Szene nicht direkt zu verändern
                    const newMaterials = [...mesh.material];
                    newMaterials[matIndex] = newMaterial;
                    mesh.material = newMaterials;
                }
            } else if (mesh.material === this.originalMaterial) {
                mesh.material = newMaterial;
            }
        }
        
        // Nach dem ersten Klick ist das "Originalmaterial" das vorherige aus der Cycle-Liste
        this.originalMaterial = newMaterial;
    }

    private collectMeshes() {
        this.meshesWithMaterial = [];
        const target = this.targetObject ?? this.gameObject;
        
        if (!this.originalMaterial) {
             if (isDevEnvironment()) console.error("CycleMaterialOnClick: Bitte weisen Sie ein 'Original Material' zu.");
             return;
        }

        target.traverse((obj: Object3D) => {  // Hier expliziten Typ hinzugefĂĽgt
            if (obj instanceof Mesh) {
                if (Array.isArray(obj.material)) {
                    if (obj.material.includes(this.originalMaterial!)) {
                        this.meshesWithMaterial.push(obj);
                    }
                } else if (obj.material === this.originalMaterial) {
                    this.meshesWithMaterial.push(obj);
                }
            }
        });
    }

    // --- USDZ Export Implementierung ---

    beforeCreateDocument(_ext: BehaviorExtension, _context: USDZExporterContext): void {
        // Temporär Fehlerbehandlung für AR-Export hinzufügen
        try {
            // Reset fĂĽr jeden Export
            this.variantModels = [];
            CycleMaterialOnClick._isAfterCreateDocumentRun = false;
            CycleMaterialOnClick._startHiddenBehaviours = [];

            // PrĂĽfung, ob die notwendigen Daten vorhanden sind
            if (!this.originalMaterial) {
                console.warn("CycleMaterialOnClick AR Export: Kein Original-Material zugewiesen");
            }
            if (this.materialCycle.length === 0) {
                console.warn("CycleMaterialOnClick AR Export: Material-Zyklus ist leer");
            }
            
            // Mesh-Sammlung sicherstellen
            this.collectMeshes();
        } catch (e) {
            console.error("CycleMaterialOnClick beforeCreateDocument Error:", e);
        }
    }

    createBehaviours(_ext: BehaviorExtension, model: USDObject, _context: USDZExporterContext): void {
        try {
            if (this.gameObject.uuid === model.uuid) {
                this.selfModel = model;
            }

            // Prüfe, ob dieses Modell eines der Mesh-Objekte ist, die wir ändern wollen
            const matchingMesh = this.meshesWithMaterial.find(m => m.uuid === model.uuid);
            if (matchingMesh && this.originalMaterial) {
                // Sicher gehen, dass wir ein Parent haben, damit wir Varianten hinzufügen können
                if (!model.parent) {
                    console.log("Erstelle Parent fĂĽr Modell:", model.name);
                    
                    // Korrekter Konstruktoraufruf mit ID (uuid) als erstem Parameter
                    const parentUUID = "parent_" + model.uuid;
                    const parent = USDObject.createEmptyParent(model); // Diese Helper-Methode nutzen anstatt direkt zu konstruieren
                    
                    if (parent) {
                        parent.name = model.name + "_parent";
                    }
                }

                // Klon fĂĽr das Originalmaterial erstellen (wird das erste sichtbare sein)
                const originalClone = model.clone();
                originalClone.name += "_variant_original";
                originalClone.material = this.originalMaterial;
                model.parent?.add(originalClone);

                // Alle anderen Varianten erstellen und initial verstecken
                const variantsForThisModel: USDObject[] = [originalClone];
                
                // Erstelle Varianten fĂĽr jedes Material im Zyklus
                for (let i = 0; i < this.materialCycle.length; i++) {
                    try {
                        const material = this.materialCycle[i];
                        if (!material) continue;
                        
                        const variantClone = model.clone();
                        // Einfacheren Namen für besser AR-Kompatibilität
                        variantClone.name += `_variant_${i}`;
                        variantClone.material = material;
                        model.parent?.add(variantClone);
                        variantsForThisModel.push(variantClone);
                    } catch (materialError) {
                        console.error(`Fehler beim Erstellen der Variante fĂĽr Material ${i}:`, materialError);
                    }
                }
                
                this.variantModels.push(variantsForThisModel);

                // Das ursprĂĽngliche Mesh "leeren", da es nur noch als Container fĂĽr die Varianten dient
                model.geometry = null;
                model.material = null;
            }
        } catch (e) {
            console.error("CycleMaterialOnClick createBehaviours Error:", e);
        }
    }
    
    afterCreateDocument(ext: BehaviorExtension, _context: USDZExporterContext): void {
        try {
            // Vermeiden von Doppel-AusfĂĽhrungen
            if (CycleMaterialOnClick._isAfterCreateDocumentRun || !this.selfModel) return;
            CycleMaterialOnClick._isAfterCreateDocumentRun = true;

            // Alle Varianten flach machen
            const allVariants = this.variantModels.flat();
            if (allVariants.length === 0) {
                console.warn("CycleMaterialOnClick: Keine Varianten erstellt fĂĽr AR Export");
                return;
            }

            // Vereinfachte AR-Verhaltensweise
            // Statt komplexer Verkettungen von Verhalten erstellen wir nur einen direkten Tap-Trigger
            // auf das Originalobjekt und wechseln dann direkt die Sichtbarkeit
            const tapVariant = allVariants[0]; // Erste Variante als Tap-Trigger verwenden
            
            // Einfachere Variante fĂĽr AR: Wir zeigen alle Materialien beim Tippen nacheinander
            for (let i = 0; i < allVariants.length; i++) {
                const currentVariant = allVariants[i];
                const nextIndex = (i + 1) % allVariants.length;
                const nextVariant = allVariants[nextIndex];

                try {
                    // Sehr einfaches Verhalten: Aktuell sichtbar -> Beim Tippen -> Nächstes sichtbar
                    const showNextAction = ActionBuilder.fadeAction(nextVariant, 0.1, true);
                    const hideCurrentAction = ActionBuilder.fadeAction(currentVariant, 0.1, false);
                    
                    const cycleBehavior = new BehaviorModel(
                        `Tap_${currentVariant.name}_ShowNext`,
                        TriggerBuilder.tapTrigger(currentVariant),
                        ActionBuilder.parallel(showNextAction, hideCurrentAction)
                    );
                    
                    ext.addBehavior(cycleBehavior);
                } catch (behaviorError) {
                    console.error(`Fehler beim Erstellen des Tap-Verhaltens fĂĽr Variante ${i}:`, behaviorError);
                }
            }

            // Verstecke alle Varianten auĂźer der ersten beim Start
            try {
                const objectsToHideOnStart = allVariants.slice(1); // Alle auĂźer dem ersten
                if (objectsToHideOnStart.length > 0) {
                    const startHiddenBehaviour = new BehaviorModel(
                        "HideVariantsOnStart",
                        TriggerBuilder.sceneStartTrigger(),
                        ActionBuilder.fadeAction(objectsToHideOnStart, 0, false)
                    );
                    ext.addBehavior(startHiddenBehaviour);
                }
            } catch (startHideError) {
                console.error("Fehler beim Erstellen des Start-Versteck-Verhaltens:", startHideError);
            }
        } catch (e) {
            console.error("CycleMaterialOnClick afterCreateDocument Error:", e);
        }
    }
}

Hi Matthias, it sounds like the issue is due to the differences in how interactions work in the browser versus iOS AR mode (i.e. Apple’s AR Quick Look for USDZ). In a regular browser context, pointer (click) events are handled by Three.js and the Needle Engine’s event system. However, when you export your scene as a USDZ for iOS AR, the experience is run in a native AR Quick Look environment that supports only a limited set of interactive triggers. This means the onPointerClick events you rely on for cycling materials may not be forwarded in AR mode.

Needle’s Everywhere Actions documentation explains that to support interactions on iOS AR, you often have to use the alternative “tap” triggers based on Apple’s Interactive USD schemas rather than standard browser pointer events. If your USDZ asset doesn’t include the necessary AR-specific triggers, your click won’t register on iOS, even though it works fine in a browser (docs/everywhere-actions.html).

A couple of suggestions:
• Verify you’re running the latest version of Needle Engine—recent updates have improved how input events are handled in AR Quick Look.
• Consider adapting your interaction logic for AR by using the specific AR triggers (sometimes involving invisible objects covering the expected hit area) as described in the Everywhere Actions guides.
• Alternatively, if possible, use a WebXR session on iOS where pointer events are handled similarly to the browser.

I hope this helps clarify the issue. Let me know if you need further details or run into any other problems!

1 replies left - Become PRO for more replies.
thread_nZY8Y7pPHyhRnVqIW0EnRHzT
Reply to this post directly if you need further help (bottom right button)

Hi there!

Can you please

  • format your code so that it’s readable (use backticks: ```ts …)
  • provide a link where the issue reproduces (e.g. on Needle Cloud)

Thanks!

(edited – moved into original post)

I was looking for the “change material on click” option, but I can’t seem to get it working

  1. Thanks for the link! I tested and it works just fine in QuickLook – I think you’ve just provided the wrong tap target. How you currently wrote the code means that in QuickLook, the image cycles through when clicking on the painting itself, not on the “button” object in front of the painting, so you probably want to adjust this so it’s the same interaction.

I was looking for the “change material on click” option, but I can’t seem to get it working

  1. Not sure what that means – you can’t find the component? Or something doesn’t work when you use it?

Please try “View in AR” here the object click (cube under the picture frame is the click object)

I based my work on this code.

export class ChangeMaterialOnClick extends Behaviour implements IPointerClickHandler, UsdzBehaviour {

    /**
     * The material that will be switched to the variant material
     */
    @serializable(Material)
    materialToSwitch?: Material;

    /**
     * The material that will be switched to
     */
    @serializable(Material)
    variantMaterial?: Material;

    /**
     * The duration of the fade effect in seconds (USDZ/Quicklook only)
     * @default 0
     */
    @serializable()
    fadeDuration: number = 0;

    start(): void {
        // initialize the object list
        this._objectsWithThisMaterial = this.objectsWithThisMaterial;
        ensureRaycaster(this.gameObject);
        if (isDevEnvironment() && this._objectsWithThisMaterial.length <= 0) {
            console.warn("ChangeMaterialOnClick: No objects found with material \"" + this.materialToSwitch?.name + "\"");
        }
    }

    onPointerEnter(_args: PointerEventData) {
        this.context.input.setCursor("pointer");
    }
    onPointerExit(_: PointerEventData) {
        this.context.input.unsetCursor("pointer");
    }
    onPointerClick(args: PointerEventData) {
        args.use();
        if (!this.variantMaterial) return;
        for (let i = 0; i < this.objectsWithThisMaterial.length; i++) {
            const obj = this.objectsWithThisMaterial[i];
            obj.material = this.variantMaterial;
        }
    }

    private _objectsWithThisMaterial: Mesh[] | null = null;
    /** Get all objects in the scene that have the assigned materialToSwitch */
    private get objectsWithThisMaterial(): Mesh[] {
        if (this._objectsWithThisMaterial != null) return this._objectsWithThisMaterial;
        this._objectsWithThisMaterial = [];
        if (this.variantMaterial && this.materialToSwitch) {
            this.context.scene.traverse(obj => {
                if (obj instanceof Mesh) {
                    if (Array.isArray(obj.material)) {
                        for (const mat of obj.material) {
                            if (mat === this.materialToSwitch) {
                                this.objectsWithThisMaterial.push(obj);
                                break;
                            }
                        }
                    }
                    else {
                        if (obj.material === this.materialToSwitch) {
                            this.objectsWithThisMaterial.push(obj);
                        }
                        else if (compareAssociation(obj.material, this.materialToSwitch)) {
                            this.objectsWithThisMaterial.push(obj);
                        }
                    }
                }
            });
        }
        return this._objectsWithThisMaterial;
    }

    private selfModel!: USDObject;
    private targetModels!: USDObject[];

    private static _materialTriggersPerId: { [key: string]: ChangeMaterialOnClick[] } = {}
    private static _startHiddenBehaviour: BehaviorModel | null = null;
    private static _parallelStartHiddenActions: USDObject[] = [];

    async beforeCreateDocument(_ext: BehaviorExtension, _context) {
        this.targetModels = [];
        ChangeMaterialOnClick._materialTriggersPerId = {}
        ChangeMaterialOnClick.variantSwitchIndex = 0;

        // Ensure that the progressive textures have been loaded for all variants and materials
        if (this.materialToSwitch) {
            await NEEDLE_progressive.assignTextureLOD(this.materialToSwitch, 0);
        }
        if (this.variantMaterial) {
            await NEEDLE_progressive.assignTextureLOD(this.variantMaterial, 0);
        }
    }


    createBehaviours(_ext: BehaviorExtension, model: USDObject, _context) {

        const shouldExport = this.objectsWithThisMaterial.find(o => o.uuid === model.uuid);
        if (shouldExport) {
            this.targetModels.push(model);
        }
        if (this.gameObject.uuid === model.uuid) {
            this.selfModel = model;
            if (this.materialToSwitch) {
                if (!ChangeMaterialOnClick._materialTriggersPerId[this.materialToSwitch.uuid])
                    ChangeMaterialOnClick._materialTriggersPerId[this.materialToSwitch.uuid] = [];
                ChangeMaterialOnClick._materialTriggersPerId[this.materialToSwitch.uuid].push(this);
            }
        }
    }

    afterCreateDocument(ext: BehaviorExtension, _context) {

        if (!this.materialToSwitch) return;
        const handlers = ChangeMaterialOnClick._materialTriggersPerId[this.materialToSwitch.uuid];
        if (handlers) {
            const variants: { [key: string]: Array<USDObject> } = {}
            for (const handler of handlers) {
                const createdVariants = handler.createVariants();
                if (createdVariants && createdVariants.length > 0)
                    variants[handler.selfModel.uuid] = createdVariants;
            }
            for (const handler of handlers) {
                const otherVariants: Array<USDObject> = [];
                for (const key in variants) {
                    if (key !== handler.selfModel.uuid) {
                        otherVariants.push(...variants[key]);
                    }
                }
                handler.createAndAttachBehaviors(ext, variants[handler.selfModel.uuid], otherVariants);
            }
        }
        delete ChangeMaterialOnClick._materialTriggersPerId[this.materialToSwitch.uuid];
    }

    private createAndAttachBehaviors(ext: BehaviorExtension, myVariants: Array<USDObject>, otherVariants: Array<USDObject>) {
        const select: ActionModel[] = [];

        const fadeDuration = Math.max(0, this.fadeDuration);

        select.push(ActionBuilder.fadeAction([...this.targetModels, ...otherVariants], fadeDuration, false));
        select.push(ActionBuilder.fadeAction(myVariants, fadeDuration, true));

        ext.addBehavior(new BehaviorModel("Select_" + this.selfModel.name,
            TriggerBuilder.tapTrigger(this.selfModel),
            ActionBuilder.parallel(...select))
        );
        ChangeMaterialOnClick._parallelStartHiddenActions.push(...myVariants);
        if (!ChangeMaterialOnClick._startHiddenBehaviour) {
            ChangeMaterialOnClick._startHiddenBehaviour =  
                new BehaviorModel("StartHidden_" + this.selfModel.name,
                    TriggerBuilder.sceneStartTrigger(),
                    ActionBuilder.fadeAction(ChangeMaterialOnClick._parallelStartHiddenActions, fadeDuration, false));
            ext.addBehavior(ChangeMaterialOnClick._startHiddenBehaviour);
        }
    }

...

I did test it on iOS. As I wrote above, it works fine – you just have the wrong “tap target”. When you click the painting the materials cycle through correctly.

The clicks are not registered for me in AR.

This link is not accessible to me.

Which iOS version / device are you on?

IOS 18.6
the video demonstrates that under AR mode the clicks do not work

For a test, could you try disabling the frame of the painting?
It could be that iOS’ automatic bounds for objects prevents tapping on the picture (which is inside the frame).

1 Like

Thanks Felix, that was it! :slight_smile:

1 Like

This topic was automatically closed 13 days after the last reply. New replies are no longer allowed.