DEV Community

Francesco Esposito
Francesco Esposito

Posted on

How I Built a 3D Product Configurator using Alpine.js & Three.js (No React required!)

The Goal
I recently took on a challenge to build a real-time 3D product configurator for custom adhesive tapes. The objective was to allow users to customize dimensions (width, length), materials, and graphical elements (text, logos) on a 2D canvas, and see the results instantly projected onto a 3D model.

Instead of reaching for heavy frameworks like React Three Fiber, I wanted to explore a lightweight, modular approach using Native ES Modules and Alpine.js for state management.

Live Demo: https://3d-config-js.netlify.app/

GitHub Repository: https://github.com/Noise1995/3d-config-js

The Architecture: A Linear Rendering Pipeline**
The core challenge of this project was not just rendering a 3D model, but synchronizing a 2D user input with a 3D texture in real-time. To manage this, I organized the codebase into a strict data pipeline where each module handles a specific stage of the transformation.

Here is how the application flows, from the user's click to the final 3D pixel:

1. The Controller (app.js)
This is the entry point and the "brain" of the application. It uses Alpine.js to manage the reactive state. It listens for user inputs (like changing width or typing text) and orchestrates the other modules. It does not handle rendering logic directly; it simply tells the other modules when to update.

2. The 2D Designer (editor2d.js)
This module wraps Fabric.js. It handles the interactive HTML canvas where users drag and drop logos or edit text. Its only responsibility is to capture user intent and provide a snapshot (Data URL) of the current design when requested.

3. The Compositor (composer.js)
This is the middleware. When the state changes, this module acts as a bridge. It takes the snapshot from the 2D Designer and merges it with the base material textures (like the paper grain of the tape). Crucially, it handles Distortion Correction. Since the 3D model scales based on the tape width, a simple texture mapping would result in stretched logos. This module mathematically compensates for that stretch before generating the final texture.

4. The 3D Engine (scene3d.js)
This module wraps Three.js. It initializes the WebGL renderer, lights, and the GLB model. It exposes specific methods (like updateTexture or updateModelScale) that the Controller calls. It remains "dumb" regarding the app logic; it only knows how to render what it is given.

Implementation Details
Reactive State with Alpine.js
I moved away from vanilla DOM manipulation to Alpine.js to keep the code declarative. The store holds the configuration and watches for changes.

JavaScript

// app.js
document.addEventListener('alpine:init', () => {
    Alpine.data('tapeConfigurator', () => ({
        config: {
            width: 50,
            length: 66,
            materialId: 'standard'
        },

        init() {
            init3D('preview-3d');

            // Watcher: When width changes, trigger the pipeline
            this.$watch('config.width', () => {
                this.refreshArtwork();
            });
        },

        refreshArtwork() {
            // Trigger the Composer -> then update the 3D Scene
            updateCompositeArtwork(this.config.width, (textureUrl) => {
                updateTexture(textureUrl);
                updateModelScale(this.config.width);
            });
        }
    }));
});
Enter fullscreen mode Exit fullscreen mode

The Compositing Logic
The most complex part was mapping the 2D canvas onto the cylinder without distortion. The composer.js creates a hidden canvas to generate the final material map.

JavaScript

// composer.js
export function updateCompositeArtwork(widthMm, onResult) {
    // 1. Get raw image from Fabric.js
    const patchData = getSnapshot(); 

    // 2. Calculate aspect ratio compensation
    const stretchFactor = widthMm / CONSTANTS.BASE_3D_WIDTH_MM;
    const compensationY = 1 / stretchFactor;

    // 3. Draw to hidden canvas with inverse scaling
    ctx.scale(1, compensationY);
    ctx.drawImage(imgPatch, x, y, width, height);

    // 4. Return new texture URL
    onResult(tempCanvas.toDataURL('image/png'));
}
Enter fullscreen mode Exit fullscreen mode

Performance Considerations
Since we are generating textures on the fly, performance is key.

Debouncing: I ensured the texture regeneration doesn't fire on every single keystroke, but batches updates.

Texture Reuse: Three.js materials are resource-heavy. Instead of creating new materials, I swap the .map property and flag needsUpdate = true.

Initialization Delay: To prevent layout thrashing or black screens, the 3D engine waits for the CSS layout to compute container dimensions before initializing the WebGL context.

Conclusion
This project demonstrates that you can build complex, interactive 3D applications without the complexity of a build step or a heavy SPA framework. By strictly separating the concerns—State (Alpine), 2D (Fabric), and 3D (Three.js)—the code remains clean, maintainable, and easy to extend.

Feel free to explore the code structure in the repository.

Top comments (0)