Script: Support The Mission Editor

Support The Mission Editor picture
Type
Typescript logo indicatortypescript
Date Created
Aug 6, 2025, 5:33:19 PM
Last Edit Date
Jan 21, 2026, 11:41:58 PM

Project Information

This 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!

View Full Project

Script Code

// ===================================================================================
// === BITBYBIT-BABYLON JS CODE - https://bitbybit.dev/app                  ===
// ===================================================================================

let text1 = "SUPPORT";
let text2 = "THE MISSION!";

interface Particle extends BABYLON.Particle {
    isLarge: boolean,
    isDrifter: boolean,
    driftDirection: BABYLON.Vector3
}

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();

const start = async () => {
    const mouseTracker = { position: null as BABYLON.Vector3 | null };

    resultAssets = await createSceneAssets(text1, text2, scene, mouseTracker);

    if (scene.metadata && scene.metadata.observable) {
        scene.metadata.observable.remove();
    }

    const resObs = scene.onPointerObservable.add((pointerInfo) => {
        if (pointerInfo.type === BABYLON.PointerEventTypes.POINTERMOVE) {
            const pickInfo = scene.pick(scene.pointerX, scene.pointerY, (mesh) => mesh === resultAssets.interactionPlane);
            if (pickInfo.hit) {
                mouseTracker.position = pickInfo.pickedPoint;
            } else {
                mouseTracker.position = null;
            }
        }
    });

    if (scene.metadata) {
        scene.metadata.observable = resObs;
    } else {
        scene.metadata = { observable: resObs };
    }
}

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;

    interactionPlane.visibility = 0;
    interactionPlane.isPickable = true;

    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);

    const particleSystem1 = createParticleSystemForText(emitter1, scene, purpleStartColor, purpleMidColor, mouseTracker);
    const particleSystem2 = createParticleSystemForText(emitter2, scene, blueStartColor, blueMidColor, mouseTracker);

    return { particleSystem1, particleSystem2, emitter1, emitter2, interactionPlane };
}

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.DrawOcctShapeOptions();
    drawOptions.drawEdges = false;
    drawOptions.drawTwoSided = false;

    const textMesh = await bitbybit.draw.drawAnyAsync({
        entity: translatedCompound,
        options: drawOptions
    });
    const emitterMesh = textMesh.getChildMeshes()[0];
    emitterMesh.isPickable = false;
    emitterMesh.visibility = 0;

    return emitterMesh as BABYLON.Mesh;
}

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(0, 0, 0), vB = new BABYLON.Vector3(0, 0, 0), vC = new BABYLON.Vector3(0, 0, 0);
    const edge1 = new BABYLON.Vector3(0, 0, 0), edge2 = new BABYLON.Vector3(0, 0, 0);

    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.x = (Math.random() - 0.5) * 0.5;
        out.y = (Math.random() - 0.5) * 0.5;
        out.z = (Math.random() - 0.5) * 0.5;
    };
    return customEmitter;
}

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; // 7% of particles will be drifters
    const DRIFTER_SPEED = 0.4;   // How strongly they push away

    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;
        const 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;
            }

            // On a particle's first update, decide its properties
            if (particle.age === scaledUpdateSpeed) {
                particle.isLarge = (Math.random() < 0.05);

                // *** NEW: Check if it's a drifter and assign a direction ***
                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
                    );
                    driftVector.normalize();
                    particle.driftDirection = driftVector;
                }
            }

            // --- PHYSICS ---
            // All particles are affected by drag
            particle.direction.scaleInPlace(this.dragFactor);

            // *** NEW: Apply physics based on particle type ***
            if (particle.isDrifter) {
                // Drifters get a constant push in their unique direction, ignoring gravity.
                const driftForce = particle.driftDirection.scale(DRIFTER_SPEED * scaledUpdateSpeed);
                particle.direction.addInPlace(driftForce);
            } else {
                // Regular particles are pulled by gravity.
                particle.direction.addInPlace(this.gravity.scale(scaledUpdateSpeed));
            }

            // Repulsion from mouse still affects all particles
            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);
                }
            }

            // Update position based on final velocity
            particle.position.addInPlace(particle.direction.scale(scaledUpdateSpeed));

            // --- VISUALS ---
            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;
}

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 fscrOpt = new Bit.Inputs.BabylonGui.CreateFullScreenUIDto();
    const fullScreenUI = gui.advancedDynamicTexture.createFullScreenUI(fscrOpt);
    const stackPanel2 = new BABYLON.GUI.StackPanel();
    stackPanel2.verticalAlignment = BABYLON.GUI.Control.VERTICAL_ALIGNMENT_BOTTOM;
    fullScreenUI.addControl(stackPanel2);

    const textInput1 = createInput(gui, text1, "#ff66ff");
    const textInput2 = createInput(gui, text2, "#5555ff");

    const onTextUpdate = async () => {
        textInput1.isEnabled = false;
        textInput2.isEnabled = false;
        if (resultAssets) {
            resultAssets.particleSystem1.dispose();
            resultAssets.particleSystem2.dispose();
            resultAssets.emitter1.dispose(false, true);
            resultAssets.emitter2.dispose(false, true);
            resultAssets.interactionPlane.dispose();
        }
        await start();
        textInput1.isEnabled = true;
        textInput2.isEnabled = true;
    };

    textInput1.onBlurObservable.add(async () => { text1 = textInput1.text; await onTextUpdate(); });
    textInput2.onBlurObservable.add(async () => { text2 = textInput2.text; await onTextUpdate(); });

    const button = BABYLON.GUI.Button.CreateSimpleButton("btn", "COPY URL FOR SHARING!");
    button.width = "400px"; button.height = "150px"; button.color = "#ffffff"; button.background = "#00000000";
    button.paddingTop = "20px"; button.paddingBottom = "20px";
    button.onPointerClickObservable.add(() => { navigator.clipboard.writeText(`https://bitbybit.dev/app/bitbybit/72BnqtviVX8QJ0VxZOnv/6yareGlkCioDVfF9o3sm/preview?occt=true&jscad=false&manifold=false&title=${encodeURIComponent(text1)}&subtitle=${encodeURIComponent(text2)}`) });

    const imageOpt = new Bit.Inputs.BabylonGui.CreateImageDto();
    imageOpt.url = "assets/logo-gold-small.png";
    const image = gui.image.createImage(imageOpt);
    const padding = 30;
    image.paddingTopInPixels = padding;
    image.paddingBottomInPixels = padding;
    image.paddingLeftInPixels = padding;
    image.paddingRightInPixels = padding;

    const txtBlockOptions1 = new Bit.Inputs.BabylonGui.CreateTextBlockDto();
    txtBlockOptions1.text = "Powered By";
    const txtBlock1 = gui.textBlock.createTextBlock(txtBlockOptions1);
    txtBlock1.height = "40px";
    txtBlock1.paddingBottomInPixels = 10;
    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(textInput1);
    stackPanel2.addControl(textInput2);
    stackPanel2.addControl(button);
    stackPanel2.addControl(image);
    stackPanel2.addControl(txtBlock1);
    stackPanel2.addControl(txtBlock);
}

function createInput(gui: Bit.BabylonGui, text: string, color: string) {
    let originalText = text;
    const textInput = new BABYLON.GUI.InputText();
    textInput.text = originalText.toUpperCase();
    textInput.width = "400px"; textInput.height = "100px";
    textInput.fontSize = "40px"; textInput.paddingTop = "10px"; textInput.paddingBottom = "10px"; textInput.color = color;
    textInput.onTextChangedObservable.add((newText) => {
        if (newText.text && newText.text.length > 20) {
            textInput.text = originalText.toUpperCase();
        } else {
            originalText = newText.text; textInput.text = newText.text.toUpperCase()
        }
    });
    return textInput;
}

start();