VR Kit Starter Project - Buggy?

Full disclosure, I am very new to all of this.

I recently stumbled upon PlayCanvas after playing around with aframe.io

I made my first project using the VR Kit template and published it without making any changes. Very cool! Testing on my Quest 3. I noticed that the snap turn function will work every single time if I turn left and right over and over, but when I try to turn the same direction multiple times it’s hit or miss.

I know very little about coding and was hoping to hit the ground running with a fully functional template. Am I being too picky and/or does someone have a VR template that is a little more polished?

1 Like

How about this one:

https://playcanvas.com/project/1378484/overview/webxr-vr-and-ar-starter-kit

I built it by dropping in some of the scripts available in the engine:

Features:

  • 100% based on ESM scripts
  • Supports both AR and VR modes out-of-the-box
  • Detects your controllers and renders the correct 3D models for them
  • Detects hands and renders skinned hands as required
  • Allows you to teleport via a pinch gesture

I’m tempted to switch the VR template to this project instead.

2 Likes

Thank you Will! I will give this template a shot tonight!

@David_G Did you manage to give it a try? It would be great to get your feedback.

@will , thanks for making this with the new ESM setup.
It’s also very nice to have the controllers dynamically loaded.

It does seem like my controllers only show up the second time I enter VR.

I am using but the WEBXR browser extention to simulate VR but it also happens when testing with a Quest 2.

Also when I change device in the simulator, the old controllers stay in the scene unresponsive. But this might just be a simulator issue.

Fixed it; the controllers were not there because it was not waiting for them to be fetched. Hence why the second time they are already fetched.

I also added laser pointers in my script.

import { Color, Script, Vec3 } from 'playcanvas';

class XrControllers extends Script {
    static scriptName = 'xrControllers';

    /**
     * The base URL for fetching the WebXR input profiles.
     *
     * @attribute
     * @type {string}
     */
    basePath = 'https://cdn.jsdelivr.net/npm/@webxr-input-profiles/assets/dist/profiles';

    controllers = new Map();
    
    /** @type {Map<XrInputSource, boolean>} */
    squeezeStates = new Map();
    
    defaultPointerColor = new Color(1, 1, 1);  // White for default state
    squeezePointerColor = new Color(0, 0, 1);  // Blue when buttons are squeezed
    pointerLength = 10;

    initialize() {
        if (!this.app.xr) {
            console.error('XrControllers script requires XR to be enabled on the application');
            return;
        }

        this.app.xr.input.on('add', async (inputSource) => {
            // Initialize squeeze state
            this.squeezeStates.set(inputSource, false);
            
            // Set up squeeze event handlers
            inputSource.on('squeezestart', () => {
                this.squeezeStates.set(inputSource, true);
            });
            
            inputSource.on('squeezeend', () => {
                this.squeezeStates.set(inputSource, false);
            });
            
            // Wait for profiles to be available with timeout
            let profiles = inputSource.profiles;
            let attempts = 0;
            const maxAttempts = 50; // 5 seconds max wait
            
            while ((!profiles || profiles.length === 0) && attempts < maxAttempts) {
                await new Promise(resolve => setTimeout(resolve, 100));
                profiles = inputSource.profiles;
                attempts++;
            }
            
            if (!profiles?.length) {
                console.log(inputSource)
                console.warn('No profiles available for input source after waiting');
                return;
            }

            // Process all profiles concurrently
            const profilePromises = inputSource.profiles.map(async (profileId) => {
                const profileUrl = `${this.basePath}/${profileId}/profile.json`;

                try {
                    const response = await fetch(profileUrl);
                    if (!response.ok) {
                        return null;
                    }

                    const profile = await response.json();
                    const layoutPath = profile.layouts[inputSource.handedness]?.assetPath || '';
                    const assetPath = `${this.basePath}/${profile.profileId}/${inputSource.handedness}${layoutPath.replace(/^\/?(left|right)/, '')}`;

                    // Load the model
                    const asset = await new Promise((resolve, reject) => {
                        this.app.assets.loadFromUrl(assetPath, 'container', (err, asset) => {
                            if (err) reject(err);
                            else resolve(asset);
                        });
                    });

                    return { profileId, asset };
                } catch (error) {
                    console.warn(`Failed to process profile ${profileId}`);
                    return null;
                }
            });

            // Wait for all profile attempts to complete
            const results = await Promise.all(profilePromises);
            const successfulResult = results.find(result => result !== null);

            if (successfulResult) {
                const { asset } = successfulResult;
                const container = asset.resource;
                const entity = container.instantiateRenderEntity();
                this.app.root.addChild(entity);

                const jointMap = new Map();
                if (inputSource.hand) {
                    for (const joint of inputSource.hand.joints) {
                        const jointEntity = entity.findByName(joint.id);
                        if (jointEntity) {
                            jointMap.set(joint, jointEntity);
                        }
                    }
                }

                this.controllers.set(inputSource, { entity, jointMap });
            } else {
                console.warn('No compatible profiles found');
            }
        });

        this.app.xr.input.on('remove', (inputSource) => {
            const controller = this.controllers.get(inputSource);
            if (controller) {
                controller.entity.destroy();
                this.controllers.delete(inputSource);
            }
            this.squeezeStates.delete(inputSource);
        });
    }

    update(dt) {
        if (this.app.xr?.active) {
            for (const [inputSource, { entity, jointMap }] of this.controllers) {
                // Update controller positions
                if (inputSource.hand) {
                    for (const [joint, jointEntity] of jointMap) {
                        jointEntity.setPosition(joint.getPosition());
                        jointEntity.setRotation(joint.getRotation());
                    }
                } else {
                    entity.setPosition(inputSource.getPosition());
                    entity.setRotation(inputSource.getRotation());
                }
                
                // Draw pointer line
                this.drawPointerLine(inputSource);
            }
        }
    }
    
    drawPointerLine(inputSource) {
        const origin = inputSource.getOrigin();
        const direction = inputSource.getDirection();
        
        // Calculate end point of the pointer line
        const endPoint = origin.clone().add(
            direction.clone().mulScalar(this.pointerLength)
        );
        
        // Choose color based on squeeze state
        const isSqueezing = this.squeezeStates.get(inputSource) || false;
        const color = isSqueezing ? this.squeezePointerColor : this.defaultPointerColor;
        
        // Draw the pointer line
        this.app.drawLine(origin, endPoint, color);
    }
}

export { XrControllers };