
typescriptThis vibe coded project invites you to experience and share the power of interactive GPU particles that appear on 3D text with others. You can set up your own text and share it with someone you love as a link. We made use of Gemini 2.5 Artificial Intelligence model to create this beautiful experience. It is now available to everyone for free. Support the mission - subscribe. Thanks!
// ===================================================================================
// === BITBYBIT-BABYLON JS CODE - FULLSCREEN PARTICLE VIEWER ===
// ===================================================================================
interface Particle extends BABYLON.Particle {
isLarge: boolean,
isDrifter: boolean,
driftDirection: BABYLON.Vector3
}
// --- URL PARAMETER LOGIC ---
// Reading text from URL parameters to display in the viewer.
const urlParams = new URLSearchParams(location.search);
let text1 = urlParams.get('title');
let text2 = urlParams.get('subtitle');
if (!text1) {
text1 = "SUPPORT";
}
if (!text2) {
text2 = "THE MISSION";
}
// --- INTERFACES ---
// This interface holds all the assets created for our scene.
interface ResultAssets {
particleSystem1: BABYLON.ParticleSystem;
particleSystem2: BABYLON.ParticleSystem;
emitter1: BABYLON.Mesh;
emitter2: BABYLON.Mesh;
interactionPlane: BABYLON.Mesh;
}
let resultAssets: ResultAssets;
const scene = bitbybit.babylon.scene.getScene();
const camera = bitbybit.babylon.scene.getActiveCamera() as BABYLON.ArcRotateCamera;
camera.position = new BABYLON.Vector3(0, 30, -20);
camera.target = new BABYLON.Vector3(0, 0, -4);
camera.minZ = 0.1;
createGUI();
// The main start function to initialize the scene.
const start = async () => {
// This object will be shared with both particle systems to track the mouse.
const mouseTracker = { position: null as BABYLON.Vector3 | null };
// Create all the 3D assets: text emitters, particle systems, and the interaction plane.
resultAssets = await createSceneAssets(text1, text2, scene, mouseTracker);
// Remove any old observable before adding a new one.
if (scene.metadata && scene.metadata.observable) {
scene.metadata.observable.remove();
}
// Centralized mouse interaction logic.
const resObs = scene.onPointerObservable.add((pointerInfo) => {
if (pointerInfo.type === BABYLON.PointerEventTypes.POINTERMOVE) {
// We only check for hits against our single, invisible interaction plane.
const pickInfo = scene.pick(scene.pointerX, scene.pointerY, (mesh) => mesh === resultAssets.interactionPlane);
if (pickInfo.hit) {
// If we hit the plane, update the shared tracker object's position.
mouseTracker.position = pickInfo.pickedPoint;
} else {
// If the mouse is not over the plane, clear the position.
mouseTracker.position = null;
}
}
});
if (scene.metadata) {
scene.metadata.observable = resObs;
} else {
scene.metadata = { observable: resObs };
}
// NOTE: No manual render loop (like registerRenderFunction) is needed!
// The particle systems update themselves automatically, which is more performant.
}
// This function creates all the necessary 3D assets for the particle effect.
async function createSceneAssets(
text1: string,
text2: string,
scene: BABYLON.Scene,
mouseTracker: { position: BABYLON.Vector3 | null }
): Promise<ResultAssets> {
const separator = 5;
const text3D1 = await create3DText(text1, 10, "BoldItalic");
const text3D2 = await create3DText(text2, 7, "BoldItalic");
const emitter1 = await createTextEmitterMesh(text3D1, separator);
const emitter2 = await createTextEmitterMesh(text3D2, -separator);
emitter1.refreshBoundingInfo(true);
emitter2.refreshBoundingInfo(true);
const combinedMin = BABYLON.Vector3.Minimize(emitter1.getBoundingInfo().boundingBox.minimumWorld, emitter2.getBoundingInfo().boundingBox.minimumWorld);
const combinedMax = BABYLON.Vector3.Maximize(emitter1.getBoundingInfo().boundingBox.maximumWorld, emitter2.getBoundingInfo().boundingBox.maximumWorld);
const padding = 4.0;
const planeWidth = (combinedMax.x - combinedMin.x) + padding;
const planeHeight = (combinedMax.z - combinedMin.z) + padding * 3;
const interactionPlane = BABYLON.MeshBuilder.CreatePlane("interactionPlane", {
width: planeWidth,
height: planeHeight
}, scene);
interactionPlane.position = combinedMin.add(combinedMax).scale(0.5);
interactionPlane.rotation.x = Math.PI / 2; // Lay the plane flat.
interactionPlane.visibility = 0; // Make it invisible.
interactionPlane.isPickable = true; // But allow mouse picking.
// Define color palettes for the two particle systems.
const purpleStartColor = new BABYLON.Color4(0.7, 0.3, 1.0, 1.0);
const purpleMidColor = new BABYLON.Color4(1.0, 0.4, 0.8, 1.0);
const blueStartColor = new BABYLON.Color4(0.2, 0.7, 1.0, 1.0);
const blueMidColor = new BABYLON.Color4(0.5, 0.8, 1.0, 1.0);
// Create the particle systems, passing the mouse tracker to them.
const particleSystem1 = createParticleSystemForText(emitter1, scene, purpleStartColor, purpleMidColor, mouseTracker);
const particleSystem2 = createParticleSystemForText(emitter2, scene, blueStartColor, blueMidColor, mouseTracker);
return { particleSystem1, particleSystem2, emitter1, emitter2, interactionPlane };
}
// Creates a solid, invisible mesh from the 3D text's faces to use as a particle emitter.
async function createTextEmitterMesh(
txt3D: Bit.Advanced.Text3D.Text3DData<Bit.Inputs.OCCT.TopoDSShapePointer>,
upDist: number
): Promise<BABYLON.Mesh> {
const faces = await bitbybit.occt.shapes.face.getFaces({ shape: txt3D.compound });
const faceCompound = await bitbybit.occt.shapes.compound.makeCompound({ shapes: faces });
const translatedCompound = await bitbybit.occt.transforms.translate({
shape: faceCompound,
translation: [0, 0, upDist]
});
const drawOptions = new Bit.Inputs.Draw.DrawOcctShapeSimpleOptions();
drawOptions.drawEdges = false;
drawOptions.drawTwoSided = false;
const textMesh = await bitbybit.draw.drawAnyAsync({ entity: translatedCompound, options: drawOptions });
const emitterMesh = textMesh.getChildMeshes()[0] as BABYLON.Mesh;
emitterMesh.isPickable = false;
emitterMesh.visibility = 0;
return emitterMesh;
}
// The core particle system definition.
function createParticleSystemForText(
emitterMesh: BABYLON.Mesh,
scene: BABYLON.Scene,
animStartColor: BABYLON.Color4,
animMidColor: BABYLON.Color4,
mouseTracker: { position: BABYLON.Vector3 | null }
): BABYLON.ParticleSystem {
const animEndColor = new BABYLON.Color4(0.1, 0.2, 0.8, 0.0);
const DRIFTER_CHANCE = 0.07;
const DRIFTER_SPEED = 0.4;
const particleSystem = new BABYLON.ParticleSystem("particles_" + emitterMesh.name, 25000, scene);
particleSystem.particleTexture = new BABYLON.Texture("https://assets.babylonjs.com/textures/flare.png", scene);
particleSystem.emitter = emitterMesh;
particleSystem.particleEmitterType = createUniformMeshParticleEmitter(emitterMesh);
particleSystem.color1 = animEndColor.clone();
particleSystem.color2 = animEndColor.clone();
particleSystem.colorDead = animEndColor.clone();
particleSystem.minSize = 0;
particleSystem.maxSize = 0;
particleSystem.blendMode = BABYLON.ParticleSystem.BLENDMODE_ONEONE;
particleSystem.minLifeTime = 4.0;
particleSystem.maxLifeTime = 8.0;
particleSystem.emitRate = 2000;
particleSystem.minEmitPower = 0.1;
particleSystem.maxEmitPower = 0.5;
particleSystem.gravity = new BABYLON.Vector3(0, -1.0, 0);
(particleSystem as any).dragFactor = 0.97;
particleSystem.updateFunction = function (particles) {
const repulsionRadius = 10.0, repulsionStrength = 30;
const scaledUpdateSpeed = this._scaledUpdateSpeed;
const mousePickPosition = mouseTracker.position;
const regularStartSize = 0.35, regularEndSize = 0.1;
const largeStartSize = 0.8, largeEndSize = 0.2;
for (let index = 0; index < particles.length; index++) {
const particle = particles[index] as Particle;
particle.age += scaledUpdateSpeed;
if (particle.age >= particle.lifeTime) {
particles.splice(index, 1); this._stockParticles.push(particle); index--; continue;
}
if (particle.age === scaledUpdateSpeed) {
particle.isLarge = (Math.random() < 0.05);
particle.isDrifter = (Math.random() < DRIFTER_CHANCE);
if (particle.isDrifter) {
const driftVector = new BABYLON.Vector3(Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5);
particle.driftDirection = driftVector.normalize();
}
}
particle.direction.scaleInPlace(this.dragFactor);
if (particle.isDrifter) {
const driftForce = particle.driftDirection.scale(DRIFTER_SPEED * scaledUpdateSpeed);
particle.direction.addInPlace(driftForce);
} else {
particle.direction.addInPlace(this.gravity.scale(scaledUpdateSpeed));
}
if (mousePickPosition) {
const distance = BABYLON.Vector3.Distance(particle.position, mousePickPosition);
if (distance < repulsionRadius) {
const forceDirection = particle.position.subtract(mousePickPosition).normalize();
const forceMagnitude = repulsionStrength * (1 - distance / repulsionRadius);
const forceVector = forceDirection.scale(forceMagnitude * scaledUpdateSpeed);
particle.direction.addInPlace(forceVector);
}
}
particle.position.addInPlace(particle.direction.scale(scaledUpdateSpeed));
const startSize = particle.isLarge ? largeStartSize : regularStartSize;
const endSize = particle.isLarge ? largeEndSize : regularEndSize;
const lifeRatio = particle.age / particle.lifeTime;
const fadeInDuration = 0.1;
if (lifeRatio < fadeInDuration) {
const fadeInRatio = lifeRatio / fadeInDuration;
particle.size = BABYLON.Scalar.Lerp(0, startSize, fadeInRatio);
BABYLON.Color4.LerpToRef(animEndColor, animStartColor, fadeInRatio, particle.color);
} else {
const mainLifeRatio = (lifeRatio - fadeInDuration) / (1 - fadeInDuration);
particle.size = BABYLON.Scalar.Lerp(startSize, endSize, mainLifeRatio);
if (mainLifeRatio < 0.5) {
BABYLON.Color4.LerpToRef(animStartColor, animMidColor, mainLifeRatio * 2, particle.color);
} else {
BABYLON.Color4.LerpToRef(animMidColor, animEndColor, (mainLifeRatio - 0.5) * 2, particle.color);
}
}
}
};
particleSystem.start();
return particleSystem;
}
// Creates a custom emitter to ensure particles are distributed evenly across a mesh surface.
function createUniformMeshParticleEmitter(mesh: BABYLON.Mesh): BABYLON.CustomParticleEmitter {
const positions = mesh.getVerticesData(BABYLON.VertexBuffer.PositionKind);
const indices = mesh.getIndices();
const totalFaces = indices.length / 3;
const cumulativeTriangleAreas: number[] = [];
let totalArea = 0;
const vA = new BABYLON.Vector3(), vB = new BABYLON.Vector3(), vC = new BABYLON.Vector3();
const edge1 = new BABYLON.Vector3(), edge2 = new BABYLON.Vector3();
for (let i = 0; i < totalFaces; i++) {
const indexA = indices[i * 3], indexB = indices[i * 3 + 1], indexC = indices[i * 3 + 2];
BABYLON.Vector3.FromArrayToRef(positions, indexA * 3, vA);
BABYLON.Vector3.FromArrayToRef(positions, indexB * 3, vB);
BABYLON.Vector3.FromArrayToRef(positions, indexC * 3, vC);
vB.subtractToRef(vA, edge1);
vC.subtractToRef(vA, edge2);
const area = BABYLON.Vector3.Cross(edge1, edge2).length() * 0.5;
totalArea += area;
cumulativeTriangleAreas.push(totalArea);
}
for (let i = 0; i < totalFaces; i++) cumulativeTriangleAreas[i] /= totalArea;
const customEmitter = new BABYLON.CustomParticleEmitter();
customEmitter.particlePositionGenerator = (index, particle, out) => {
const random = Math.random();
let triangleIndex = 0;
for (let i = 0; i < totalFaces; i++) if (random < cumulativeTriangleAreas[i]) { triangleIndex = i; break; }
const iA = indices[triangleIndex * 3], iB = indices[triangleIndex * 3 + 1], iC = indices[triangleIndex * 3 + 2];
BABYLON.Vector3.FromArrayToRef(positions, iA * 3, vA);
BABYLON.Vector3.FromArrayToRef(positions, iB * 3, vB);
BABYLON.Vector3.FromArrayToRef(positions, iC * 3, vC);
let r1 = Math.random(), r2 = Math.random();
if (r1 + r2 > 1) { r1 = 1 - r1; r2 = 1 - r2; }
vB.subtractToRef(vA, edge1);
vC.subtractToRef(vA, edge2);
out.copyFrom(vA).addInPlace(edge1.scaleInPlace(r1)).addInPlace(edge2.scaleInPlace(r2));
};
customEmitter.particleDestinationGenerator = (index, particle, out) => {
out.set(Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5);
};
return customEmitter;
}
// Helper function to simplify 3D text creation.
async function create3DText(text: string, fontSize: number, fontVariant: "BoldItalic" | "Regular") {
const textOpt = new Bit.Advanced.Text3D.Text3DDto();
textOpt.text = text.toUpperCase();
textOpt.fontType = Bit.Advanced.Text3D.fontsEnum.Roboto;
textOpt.fontVariant = Bit.Advanced.Text3D.fontVariantsEnum[fontVariant];
textOpt.height = 0;
textOpt.fontSize = fontSize;
textOpt.originAlignment = Bit.Advanced.Text3D.recAlignmentEnum.centerMiddle;
return bitbybit.advanced.text3d.create(textOpt);
}
function createGUI() {
const gui = bitbybit.babylon.gui;
const textOpt = new Bit.Inputs.BabylonGui.CreateFullScreenUIDto();
const fullScreenUI = gui.advancedDynamicTexture.createFullScreenUI(textOpt);
const stackPanelOpt = new Bit.Inputs.BabylonGui.CreateStackPanelDto();
const stackPanel = gui.stackPanel.createStackPanel(stackPanelOpt);
const stackPanelOpt2 = new Bit.Inputs.BabylonGui.CreateStackPanelDto();
const stackPanel2 = gui.stackPanel.createStackPanel(stackPanelOpt2);
stackPanel2.verticalAlignment = BABYLON.GUI.Control.VERTICAL_ALIGNMENT_BOTTOM;
fullScreenUI.addControl(stackPanel2);
stackPanel.paddingBottomInPixels = 10;
stackPanel2.addControl(stackPanel);
stackPanel2.background = "#00000000"
stackPanel.background = "#00000000"
const imageOpt = new Bit.Inputs.BabylonGui.CreateImageDto();
imageOpt.url = "assets/logo-gold-small.png";
const image = gui.image.createImage(imageOpt);
const padding = 50;
image.paddingTopInPixels = padding;
image.paddingBottomInPixels = padding;
image.paddingLeftInPixels = padding;
image.paddingRightInPixels = padding;
image.onPointerClickObservable.add(() => {
window.open("https://bitbybit.dev", '_blank').focus();
});
const txtBlockOptions = new Bit.Inputs.BabylonGui.CreateTextBlockDto();
txtBlockOptions.text = "BITBYBIT.DEV";
const txtBlock = gui.textBlock.createTextBlock(txtBlockOptions);
txtBlock.height = "60px";
txtBlock.paddingBottomInPixels = 30;
stackPanel2.addControl(image);
stackPanel2.addControl(txtBlock);
}
// Start the experience.
start();Select the perfect plan for your 3D development needs