Canvatorium: creating a Vue Composable with ChatGPT
ChatGPT guided me to a solution that I would not have found on my own.
One of the problems with the legacy version of the project was the amount of boilerplate code needed to set up a new lab. I tried to abstract it away as much as I could, but as I added new features it grew increasingly complex. For example, a new lab started with all this code:
<script setup>
import { labNotes } from "../composables/LabData";
import * as BABYLON from "babylonjs";
import * as GUI from "babylonjs-gui";
import { ref, onMounted, onUnmounted, watch } from "@vue/runtime-core";
import LabLayout from "../components/LabLayout.vue";
import addLabCamera from "../lab-shared/LabCamera";
import addLabLights from "../lab-shared/LabLights";
import addLabRoom from "../lab-shared/LabRoom";
labNotes.value = `
Some markdown describing the lab
`;
const bjsCanvas = ref(null);
let engine;
let scene;
const createScene = async (canvas) => {
// Create and customize the scene
engine = new BABYLON.Engine(canvas);
scene = new BABYLON.Scene(engine);
// Use the shared lab tools
addLabCamera(canvas, scene);
addLabLights(scene);
const ground = addLabRoom(scene);
// ADD THE ACTUAL LAB CODE HERE
// WebXRDefaultExperience
const xrDefault = await scene.createDefaultXRExperienceAsync({
floorMeshes: [ground],
});
const xrHelper = xrDefault.baseExperience;
console.info("webxr:", xrHelper);
engine.runRenderLoop(() => {
scene.render();
});
window.addEventListener("resize", resizeListener);
};
const resizeListener = () => {
if (engine) {
engine.resize();
}
};
onMounted(() => {
if (bjsCanvas.value) {
createScene(bjsCanvas.value);
}
});
onUnmounted(() => {
engine.dispose();
window.removeEventListener("resize", resizeListener);
});
</script>
<template>
<LabLayout :labNotes="labNotes">
<template v-slot:scene>
<canvas style="overflow: hidden" id="bjsCanvas" ref="bjsCanvas" />
</template>
</LabLayout>
</template>
It’s not great 🤦🏻♂️
When starting the new project, I wanted a more maintainable solution. I started by implementing a few core lab features in a .ts
file: labRoom, labLights, and labCamera. Over the course of a few hours, I worked with ChatGPT to produce a better solution. I love this type of dialectic development, where two developers can work through a problem by talking it over. In this case, one of the developers was a large language model.
I wish there was a way to make one of those sessions public so I could link to it. Failing that, I asked ChatGPT to describe what we did.
In our conversation, we worked on creating a Vue composable called
useCanvatoriumScene
that uses Babylon.js to create a 3D scene. The composable takes in a canvas element, a function to create the content of the lab, and options for configuring the scene. We also created a higher order function calledcreateLabScene
that handles creating the Babylon.js scene and adding various elements to it, like a camera, lights, and a room.Throughout our conversation, we encountered a few errors and made adjustments to the code to fix them. We also added some new features, like support for WebXR teleportation and a dynamic resizing function.
ChatGPT, March 25 2023
What started as a file full of helper files morphed into a new Vue Composable that does the following
- Manage canvas binding, along with engine resizing when the window/screen changes
- Configure the lab scene with the features I need for that lab using a config object
- Execute the entire lab code in a function called
createLabContent
. I can pass that function in as an argument when callinguseCanvatoriumScene
.
import { onMounted, onUnmounted, Ref } from "vue";
import { ArcRotateCamera, Scene, Engine, Vector3, Color3, Color4, MeshBuilder, HemisphericLight, GroundMesh } from "babylonjs";
import { GridMaterial } from "babylonjs-materials";
interface LabSceneOptions {
useCamera?: boolean;
useLights?: boolean;
useRoom?: boolean;
useWebXRPlayer?: boolean;
}
export const useCanvatoriumScene = (bjsCanvas: Ref<HTMLCanvasElement | null>, createLabContent: (scene: Scene) => void, options?: LabSceneOptions) => {
let engine: Engine | null = null;
const handleResize = () => {
if (engine) {
engine.resize();
}
};
onMounted(() => {
if (bjsCanvas.value) {
const { engine: createdEngine } = createLabScene(bjsCanvas.value, createLabContent, options);
engine = createdEngine;
window.addEventListener("resize", handleResize);
}
});
onUnmounted(() => {
if (engine) {
engine.dispose();
}
window.removeEventListener("resize", handleResize);
});
};
const createLabScene = (canvas: HTMLCanvasElement, createLabContent: (scene: Scene) => void, options?: LabSceneOptions) => {
const engine = new Engine(canvas);
const scene = new Scene(engine);
const defaultOptions: LabSceneOptions = {
useCamera: true,
useLights: true,
useRoom: true,
useWebXRPlayer: true
};
const mergedOptions = { ...defaultOptions, ...options };
let teleportMeshes: GroundMesh[] = [];
if (mergedOptions.useCamera) {
labCreateCamera(canvas, scene);
}
if (mergedOptions.useRoom) {
teleportMeshes.push(labCreateRoom(scene));
}
if (mergedOptions.useLights) {
labCreateLights(scene);
}
if (mergedOptions.useWebXRPlayer && teleportMeshes.length > 0) {
lapCreateWebXRPlayer(scene, teleportMeshes);
}
createLabContent(scene);
engine.runRenderLoop(() => {
scene.render();
});
window.addEventListener("resize", () => {
engine.resize();
});
return {
engine,
scene
};
};
const labCreateCamera = (canvas: HTMLCanvasElement, scene: Scene) => {
// Add an ArcRotateCamera to the scene and attach it to the canvas
// ArcRotateCamera is used to rotate the camera around the scene when not using WebXR
const camera = new ArcRotateCamera("camera", -Math.PI / 2, Math.PI / 2.5, 3, new Vector3(0, 0, 0), scene);
camera.wheelDeltaPercentage = 0.01;
camera.upperBetaLimit = Math.PI / 1.5;
camera.lowerRadiusLimit = 2;
camera.upperRadiusLimit = 128;
camera.setPosition(new Vector3(0, 3.5, -6));
camera.setTarget(new Vector3(0, 1, 0));
camera.attachControl(canvas, true);
};
const labCreateRoom = (scene: Scene) => {
// Add a ground plane to the scene. Used for WebXR teleportation
const ground = MeshBuilder.CreateGround("ground", { width: 20, height: 20 }, scene);
// Create the grid material
const groundMaterial = new GridMaterial("ground-mat", scene);
groundMaterial.majorUnitFrequency = 5;
groundMaterial.gridRatio = 1;
groundMaterial.backFaceCulling = false;
groundMaterial.lineColor = Color3.FromHexString(labColors.slate8);
groundMaterial.mainColor = Color3.FromHexString(labColors.slate7);
groundMaterial.opacity = 0.98;
// Assign the material to the ground mesh
ground.material = groundMaterial;
// Create 4 walls around the room with the same material
const wall1 = MeshBuilder.CreatePlane("wall1", { width: 20, height: 10 }, scene);
wall1.position = new Vector3(-10, 5, 0);
wall1.rotation.y = Math.PI / 2;
wall1.material = groundMaterial;
const wall2 = MeshBuilder.CreatePlane("wall2", { width: 20, height: 10 }, scene);
wall2.position = new Vector3(0, 5, 10);
wall2.rotation.y = Math.PI;
wall2.material = groundMaterial;
const wall3 = MeshBuilder.CreatePlane("wall3", { width: 20, height: 10 }, scene);
wall3.position = new Vector3(10, 5, 0);
wall3.rotation.y = -Math.PI / 2;
wall3.material = groundMaterial;
const wall4 = MeshBuilder.CreatePlane("wall4", { width: 20, height: 10 }, scene);
wall4.position = new Vector3(0, 5, -10);
wall4.material = groundMaterial;
// Return the ground to use for teleportation
return ground;
};
const labCreateLights = (scene: Scene) => {
// Customize the scene lighting and background color
const ambientLight1 = new HemisphericLight("light-01", new Vector3(5, 5, 5), scene);
ambientLight1.intensity = 0.8;
const ambientLight2 = new HemisphericLight("light-02", new Vector3(-5, 5, -5), scene);
ambientLight2.intensity = 0.8;
// set the scene color to
scene.clearColor = Color4.FromHexString(labColors.slate1);
};
const lapCreateWebXRPlayer = async (scene: Scene, teleportMeshes: GroundMesh[]) => {
const xr = await scene.createDefaultXRExperienceAsync({
floorMeshes: teleportMeshes
});
console.log("xr player created", xr);
};
An example of using this new composable in Lab 000
<script setup>
// Most imports are handled by Nuxt
import { Vector3, Color3, Color4, MeshBuilder, StandardMaterial } from "babylonjs";
import { AdvancedDynamicTexture, TextBlock } from "babylonjs-gui";
const bjsCanvas = ref(null);
// Add lab-specific content here using the provided 'scene' instance
const createLabContent = async (scene) => {
scene.getCameraByName("camera").position = new Vector3(0, 1.4, -4);
const material = new StandardMaterial("background-material", scene);
material.diffuseColor = new Color3.FromHexString(labColors.slate2)
material.alpha = 0.8;
const background = MeshBuilder.CreateBox("background", { width: 3, height: 1, depth: 0.1 });
background.material = material;
background.position.y = 1.1;
background.enableEdgesRendering();
background.edgesWidth = 1.5;
background.edgesColor = new Color4.FromHexString(labColors.slate7);
const guiPlane = MeshBuilder.CreatePlane("gui-plane")
guiPlane.parent = background;
guiPlane.position.y = 0.14;
guiPlane.position.z = -0.11;
const advancedTexture = AdvancedDynamicTexture.CreateForMesh(guiPlane);
advancedTexture.name = "card-texture";
const cardText = new TextBlock("card-text");
cardText.text = "Canvatorium";
cardText.color = labColors.slate8;
cardText.fontSize = 64;
const subtitleText = new TextBlock("subtitle-text");
subtitleText.text = "(revamped)";
subtitleText.color = labColors.slate7;
subtitleText.fontSize = 32;
subtitleText.top = 60;
advancedTexture.addControl(cardText);
advancedTexture.addControl(subtitleText);
guiPlane.scaling = new Vector3(5, 5, 5);
};
const labSceneOptions = {
useCamera: true,
useLight: true,
useRoom: true,
};
// With scene options
useCanvatoriumScene(bjsCanvas, createLabContent, labSceneOptions);
</script>
<template>
<div>
<h3 class="text-lg font-bold">Lab 000 – Hello Canvatorium</h3>
<p>
This is a recreation of the original Canvatorium Lab 000. Just setting up the shared lab resources. Grid, Lights, Camera, etc.
</p>
<div>
<canvas id="bjsCanvas" ref="bjsCanvas" />
</div>
</div>
</template>
There are a few more features I need to add to useCanvatoriumScene
but I think I have a solid foundation this time. What do you think of this approach?