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
will
August 20, 2025, 8:15pm
2
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!
will
September 3, 2025, 10:00am
4
@David_G Did you manage to give it a try? It would be great to get your feedback.
Ramon
September 8, 2025, 2:53pm
5
@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.
Ramon
September 9, 2025, 8:41am
6
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 };