3D Kaleidoscope

Complete standalone source code viewer with syntax highlighting. Note inline css and its all in one shot, so easier to view the code, but in production don't use inline css please.

Open Live Demo →
HTML / Three.js / WebGL
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>3D Shiny Kaleidoscope Multi-Instance</title>
    <style>
        body {
            margin: 0;
            overflow: hidden;
            background-color: #000000;
            font-family: sans-serif;
            color: white;
            user-select: none;
        }

        #grid-container {
            position: fixed;
            top: 0;
            left: 0;
            width: 100vw;
            height: 100vh;
            display: grid;
            gap: 2px;
            background: #000;
            z-index: 1;
        }

        .kaleidoscope-instance-wrapper {
            position: relative;
            width: 100%;
            height: 100%;
            overflow: hidden;
            background: #000;
        }

        .canvasMaskContainer {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background-color: #000000;
            z-index: 1;
            mask-size: contain;
            mask-position: center;
            mask-repeat: no-repeat;
            -webkit-mask-size: contain;
            -webkit-mask-position: center;
            -webkit-mask-repeat: no-repeat;
        }

        canvas {
            display: block;
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
        }

        #ui {
            position: absolute;
            top: 15px;
            left: 15px;
            z-index: 20;
            background: rgba(10, 10, 20, 0.9);
            padding: 15px;
            border-radius: 8px;
            backdrop-filter: blur(8px);
            border: 1px solid rgba(255, 255, 255, 0.15);
            box-shadow: 0 10px 30px rgba(0,0,0,0.6);
            width: 280px;
            max-height: 90vh;
            overflow-y: auto;
            pointer-events: auto;
        }

        #ui-header {
            cursor: grab;
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin-top: 0;
            margin-bottom: 10px;
            border-bottom: 1px solid rgba(255,255,255,0.1);
            padding-bottom: 8px;
        }
        #ui-header:active { cursor: grabbing; }
        #ui-header h3 { margin: 0; font-size: 16px; color: #00ffff; }
        .drag-handle-icon { font-size: 14px; color: rgba(255,255,255,0.4); letter-spacing: 2px; }

        .control { margin-bottom: 12px; }
        
        .mask-section, .effects-section {
            border: 1px solid rgba(0, 255, 255, 0.3);
            background: rgba(0, 255, 255, 0.03);
            padding: 10px;
            border-radius: 6px;
            margin-bottom: 12px;
        }

        label {
            display: flex;
            justify-content: space-between;
            font-size: 13px;
            margin-bottom: 4px;
            align-items: center;
        }
        input[type="range"] { width: 100%; }
        button {
            width: 100%;
            background: #2b2b36;
            color: #fff;
            border: 1px solid #555;
            padding: 8px;
            cursor: pointer;
            border-radius: 4px;
        }
        button:hover { background: #444; }

        #colorButtonGroup {
            display: flex;
            flex-wrap: wrap;
            gap: 4px;
            margin-top: 4px;
        }
        .color-btn {
            background: #2b2b36;
            color: #fff;
            border: 1px solid #555;
            padding: 6px 8px;
            cursor: pointer;
            border-radius: 4px;
            font-size: 11px;
            flex: 1 1 calc(33% - 4px);
            text-align: center;
            transition: all 0.2s;
        }
        .color-btn:hover { background: #444; }
        .color-btn.active {
            border: 2px solid #fff;
            box-shadow: 0 0 8px rgba(255, 255, 255, 0.6);
            font-weight: bold;
        }

        #imageThumbnails, #maskThumbnails {
            display: flex;
            gap: 8px;
            margin-top: 6px;
            flex-wrap: wrap;
        }
        .thumb-wrapper {
            position: relative;
            width: 50px;
            height: 50px;
        }
        .img-thumb {
            width: 100%;
            height: 100%;
            object-fit: cover;
            border: 2px solid #555;
            border-radius: 4px;
            cursor: pointer;
            opacity: 0.5;
            transition: all 0.2s;
            background: #222;
        }
        .img-thumb.active {
            border-color: #00ffff;
            opacity: 1.0;
            box-shadow: 0 0 8px rgba(0, 255, 255, 0.6);
        }
        .delete-thumb {
            position: absolute;
            top: -5px;
            right: -5px;
            background: #ff3333;
            color: white;
            border: none;
            border-radius: 50%;
            width: 18px;
            height: 18px;
            font-size: 10px;
            line-height: 18px;
            text-align: center;
            cursor: pointer;
            padding: 0;
            box-shadow: 0 2px 4px rgba(0,0,0,0.5);
        }
        .delete-thumb:hover { background: #ff0000; }
    </style>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
</head>
<body>

    <div id="grid-container"></div>

    <div id="ui">
        <div id="ui-header">
            <h3>Shiny Kaleidoscope</h3>
            <span class="drag-handle-icon">:::</span>
        </div>
        <p style="font-size: 12px; margin-top: 0; color: rgba(255,255,255,0.7);">Draw a shape with your mouse, then adjust controls.</p>
        
        <div class="control" style="border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 10px; margin-bottom: 10px;">
            <label style="color: #00ffff; font-weight: bold;">Instances (1-10): <span id="instanceVal">1</span></label>
            <input type="range" id="instanceSlider" min="1" max="10" value="1">
        </div>

        <div class="control">
            <label style="color: #ffff00; font-weight: bold;">
                Zoom / Roll Forward: 
                <input type="checkbox" id="zoomToggleCheckbox" style="cursor: pointer; width: 18px; height: 18px;">
            </label>
        </div>

        <div class="control">
            <label>Shape Count: <span id="countVal">1000</span></label>
            <input type="range" id="countSlider" min="2" max="5000" value="1000">
        </div>

        <div class="control">
            <label>Shape Scale: <span id="scaleVal">1.5</span></label>
            <input type="range" id="scaleSlider" min="0.1" max="3.0" step="0.1" value="1.5">
        </div>

        <div class="control">
            <label>Radial Symmetry: <span id="symVal">8</span></label>
            <input type="range" id="symSlider" min="2" max="24" value="8">
        </div>

        <div class="control">
            <label>Scatter Radius: <span id="radiusVal">12</span></label>
            <input type="range" id="radiusSlider" min="2" max="30" value="12">
        </div>

        <div class="control">
            <label>Colors:</label>
            <div id="colorButtonGroup">
                <button type="button" class="color-btn active" data-color="random">Random</button>
                <button type="button" class="color-btn" data-color="#ff3333">Red</button>
                <button type="button" class="color-btn" data-color="#33ff33">Green</button>
                <button type="button" class="color-btn" data-color="#3333ff">Blue</button>
                <button type="button" class="color-btn" data-color="#ffff33">Yellow</button>
                <button type="button" class="color-btn" data-color="#ff33ff">Magenta</button>
                <button type="button" class="color-btn" data-color="#33ffff">Cyan</button>
            </div>
        </div>

        <div class="control">
            <label style="color: #00ffff; font-weight: bold;">
                Custom Texture Images (Max 3):
                <input type="checkbox" id="textureToggleCheckbox" style="cursor: pointer; width: 18px; height: 18px;">
            </label>
            <input type="file" id="imageUploadInput" accept="image/*" multiple style="display: none;">
            <button type="button" id="uploadTriggerBtn">Upload Images (0/3)</button>
            <div id="imageThumbnails"></div>
        </div>

        <div class="effects-section">
            <label style="font-weight: bold; color: #ff00ff; margin-bottom: 6px;">Animation Effects:</label>
            
            <div class="control" style="margin-bottom: 6px;">
                <label style="cursor: pointer; font-size: 12px;">
                    Pulsing / Bulge Effect:
                    <input type="checkbox" id="pulseToggleCheckbox" style="cursor: pointer; width: 16px; height: 16px;">
                </label>
            </div>

            <div class="control" style="margin-bottom: 0;">
                <label style="cursor: pointer; font-size: 12px;">
                    Breathing Scale Wobble:
                    <input type="checkbox" id="wobbleToggleCheckbox" style="cursor: pointer; width: 16px; height: 16px;">
                </label>
            </div>
        </div>

        <div class="mask-section">
            <div class="control" style="margin-bottom: 8px;">
                <label style="cursor: pointer; font-weight: bold; color: #00ffff;">
                    Enable Mask Overlay: 
                    <input type="checkbox" id="maskToggleCheckbox" style="cursor: pointer; width: 18px; height: 18px;">
                </label>
            </div>
            <div class="control" style="margin-bottom: 0;">
                <label style="margin-bottom: 4px;">Mask Image:</label>
                <input type="file" id="maskUploadInput" accept="image/*" style="display: none;">
                <button type="button" id="maskUploadTriggerBtn">Upload Mask Image</button>
                <div id="maskThumbnails"></div>
            </div>
        </div>

        <button id="clearBtn">Clear / Draw New Shape</button>
    </div>

    <script>
        class MaskController {
            constructor(containerElement, initialUrl, options = {}) {
                this.container = typeof containerElement === 'string'
                    ? document.querySelector(containerElement)
                    : containerElement;
                    
                this.maskUrl = initialUrl;
                this.maskAspect = 1.0;
                this.options = Object.assign({
                    enabled: false,
                    inward: false,
                    recessedDepth: true
                }, options);

                this.init();
            }

            init() {
                if (!this.container) return;
                this.container.style.position = 'absolute';
                this.container.style.top = '0';
                this.container.style.left = '0';
                this.container.style.width = '100%';
                this.container.style.height = '100%';
                this.container.style.overflow = 'hidden';
                this.applyMaskState();
            }

            setMaskImage(url) {
                const img = new Image();
                img.onload = () => {
                    this.maskAspect = img.width / img.height;
                    this.maskUrl = url;
                    this.applyMaskState();
                };
                img.src = url;
            }

            setEnabled(enabled) {
                this.options.enabled = enabled;
                this.applyMaskState();
            }

            updateMaskSizeByAspect() {
                const w = this.container.clientWidth;
                const h = this.container.clientHeight;
                const screenAspect = w / h;
                let sizing = 'contain';
                if (w > 1600) {
                    sizing = 'contain';
                } else if (this.maskAspect > screenAspect) {
                    sizing = '100% auto';
                } else {
                    sizing = 'auto 100%';
                }
                this.container.style.maskSize = sizing;
                this.container.style.webkitMaskSize = sizing;
            }

            applyMaskState() {
                if (this.options.enabled && this.maskUrl) {
                    const encodedUrl = `url('${this.maskUrl}')`;
                    this.container.style.maskImage = encodedUrl;
                    this.container.style.webkitMaskImage = encodedUrl;
                    this.container.style.maskRepeat = 'no-repeat';
                    this.container.style.webkitMaskRepeat = 'no-repeat';
                    this.container.style.maskPosition = 'center';
                    this.container.style.webkitMaskPosition = 'center';
                    this.container.style.backgroundColor = '#000000';
                    this.container.style.maskComposite = 'exclude';
                    this.container.style.webkitMaskComposite = 'xor';
                    this.container.style.boxShadow = 'none';

                    this.updateMaskSizeByAspect();
                } else {
                    this.container.style.maskImage = 'none';
                    this.container.style.webkitMaskImage = 'none';
                    this.container.style.backgroundColor = 'transparent';
                    this.container.style.boxShadow = 'none';
                }
            }
        }

        class ShinyKaleidoscope {
            constructor(containerElement, options = {}) {
                this.container = typeof containerElement === 'string' 
                    ? document.querySelector(containerElement) 
                    : containerElement;

                this.options = Object.assign({
                    count: 1000,
                    symmetry: 8,
                    scale: 1.5,
                    scatterRadius: 12,
                    zoomMode: false,
                    pulseMode: false,
                    wobbleMode: false
                }, options);

                this.MAX_INSTANCES = 5000;
                this.instanceData = [];
                this.instancedMesh = null;
                this.baseGeometry = null;

                this.raycaster = new THREE.Raycaster();
                this.mouse = new THREE.Vector2();
                this.plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
                this.time = 0;

                this.init();
            }

            init() {
                this.scene = new THREE.Scene();
                this.camera = new THREE.PerspectiveCamera(60, this.container.clientWidth / this.container.clientHeight, 0.1, 1000);
                this.camera.position.z = 35;

                this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
                this.renderer.setSize(this.container.clientWidth, this.container.clientHeight);
                this.renderer.setPixelRatio(window.devicePixelRatio);
                this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
                this.renderer.toneMappingExposure = 1.2;
                this.container.appendChild(this.renderer.domElement);

                const pmremGenerator = new THREE.PMREMGenerator(this.renderer);
                pmremGenerator.compileEquirectangularShader();
                
                const envScene = new THREE.Scene();
                const envLight1 = new THREE.PointLight(0xffffff, 50, 100);
                envLight1.position.set(10, 10, 10);
                const envLight2 = new THREE.PointLight(0x00ffff, 50, 100);
                envLight2.position.set(-10, -10, -10);
                envScene.add(envLight1, envLight2);
                this.envMap = pmremGenerator.fromScene(envScene).texture;

                this.scene.add(new THREE.AmbientLight(0xffffff, 0.4));
                this.shinyLight = new THREE.PointLight(0xffffff, 2, 100);
                this.shinyLight.position.set(15, 20, 25);
                this.scene.add(this.shinyLight);

                for (let i = 0; i < this.MAX_INSTANCES; i++) {
                    this.instanceData.push({
                        distance: Math.random(), 
                        angleOffset: Math.random() * Math.PI * 2,
                        z: (Math.random() - 0.5) * 20,
                        rotX: Math.random() * Math.PI * 2,
                        rotY: Math.random() * Math.PI * 2,
                        baseScale: 0.1 + Math.random() * 0.4,
                        color: new THREE.Color(),
                        imageIdx: 0
                    });
                }
                this.updateInstanceData('random', []);

                this.container.addEventListener('mousedown', (e) => this.onMouseDown(e));
                window.addEventListener('mousemove', (e) => this.onMouseMove(e));
                window.addEventListener('mouseup', () => this.onMouseUp());

                this.resizeObserver = new ResizeObserver(entries => {
                    for (let entry of entries) {
                        const w = entry.contentRect.width;
                        const h = entry.contentRect.height;
                        if (w > 0 && h > 0) {
                            this.camera.aspect = w / h;
                            this.camera.updateProjectionMatrix();
                            this.renderer.setSize(w, h);
                        }
                    }
                });
                this.resizeObserver.observe(this.container);

                this.createDefaultShape();
                this.animate = this.animate.bind(this);
                requestAnimationFrame(this.animate);
            }

            createDefaultShape() {
                const shape = new THREE.Shape();
                const pts = [[-2,-2],[2,-2],[2,2],[-2,2]];
                pts.forEach((p, idx) => idx === 0 ? shape.moveTo(p[0], p[1]) : shape.lineTo(p[0], p[1]));
                shape.closePath();

                this.baseGeometry = new THREE.ExtrudeGeometry(shape, {
                    depth: 0.6, bevelEnabled: true, bevelThickness: 0.2, bevelSize: 0.2, bevelSegments: 5
                });
                this.baseGeometry.center();
                this.buildInstancedMesh();
            }

            getIntersectionPoint(e) {
                const rect = this.container.getBoundingClientRect();
                this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
                this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
                this.raycaster.setFromCamera(this.mouse, this.camera);
                const target = new THREE.Vector3();
                this.raycaster.ray.intersectPlane(this.plane, target);
                return target;
            }

            onMouseDown(e) {
                if (e.target.closest('#ui')) return;
                this.clearScene();
                this.isDrawing = true;
                this.userPoints = [];
                const pt = this.getIntersectionPoint(e);
                this.userPoints.push(new THREE.Vector2(pt.x, pt.y));
            }

            onMouseMove(e) {
                if (!this.isDrawing) return;
                const pt = this.getIntersectionPoint(e);
                const lastPt = this.userPoints[this.userPoints.length - 1];
                if (!lastPt || lastPt.distanceTo(new THREE.Vector2(pt.x, pt.y)) > 0.3) {
                    this.userPoints.push(new THREE.Vector2(pt.x, pt.y));
                    if (this.drawnLineMesh) this.scene.remove(this.drawnLineMesh);
                    const geometry = new THREE.BufferGeometry().setFromPoints(this.userPoints);
                    this.drawnLineMesh = new THREE.Line(geometry, new THREE.LineBasicMaterial({ color: 0x00ffff }));
                    this.scene.add(this.drawnLineMesh);
                }
            }

            onMouseUp() {
                if (!this.isDrawing) return;
                this.isDrawing = false;
                if (this.drawnLineMesh) { this.scene.remove(this.drawnLineMesh); this.drawnLineMesh = null; }

                if (this.userPoints.length > 2) {
                    const shape = new THREE.Shape(this.userPoints);
                    shape.closePath();
                    this.baseGeometry = new THREE.ExtrudeGeometry(shape, {
                        depth: 0.6, bevelEnabled: true, bevelThickness: 0.2, bevelSize: 0.2, bevelSegments: 5
                    });
                    this.baseGeometry.center();
                    this.buildInstancedMesh();
                }
            }

            clearScene() {
                if (this.instancedMesh) {
                    this.scene.remove(this.instancedMesh);
                    this.instancedMesh.geometry.dispose();
                    this.instancedMesh.material.dispose();
                    this.instancedMesh = null;
                }
            }

            updateInstanceData(colorMode, activeImages, activeColors = []) {
                for (let i = 0; i < this.MAX_INSTANCES; i++) {
                    if (activeImages.length > 0) {
                        this.instanceData[i].imageIdx = Math.floor(Math.random() * activeImages.length);
                    } else if (colorMode === 'random' || activeColors.length === 0) {
                        this.instanceData[i].color.setHSL(Math.random(), 0.95, 0.55);
                    } else {
                        const chosen = activeColors[Math.floor(Math.random() * activeColors.length)];
                        this.instanceData[i].color.set(chosen);
                    }
                }
            }

            createCompositeTexture(activeImages) {
                if (activeImages.length === 0) return null;
                const canvas = document.createElement('canvas');
                const size = 512;
                canvas.width = size * activeImages.length;
                canvas.height = size;
                const ctx = canvas.getContext('2d');
                activeImages.forEach((item, idx) => {
                    ctx.drawImage(item.imgElement, idx * size, 0, size, size);
                });
                const texture = new THREE.CanvasTexture(canvas);
                texture.wrapS = THREE.RepeatWrapping;
                texture.wrapT = THREE.RepeatWrapping;
                texture.needsUpdate = true;
                return texture;
            }

            buildInstancedMesh(activeImages = [], colorMode = 'random', activeColors = [], useTextures = false) {
                if (!this.baseGeometry) return;
                this.clearScene();

                const activeImgObjs = useTextures ? activeImages.filter(item => item.active) : [];
                const compositeTexture = this.createCompositeTexture(activeImgObjs);
                this.updateInstanceData(colorMode, activeImgObjs, activeColors);

                const hasImages = compositeTexture !== null;
                const material = new THREE.MeshStandardMaterial({
                    roughness: 0.2,
                    metalness: hasImages ? 0.1 : 0.85,
                    envMap: this.envMap,
                    envMapIntensity: hasImages ? 1.2 : 2.5,
                    map: compositeTexture
                });

                if (hasImages && activeImgObjs.length > 1) {
                    material.onBeforeCompile = (shader) => {
                        shader.vertexShader = `
                            attribute float aImageIdx;
                            varying float vImageIdx;
                            ${shader.vertexShader}
                        `.replace(`#include <begin_vertex>`, `#include <begin_vertex>\nvImageIdx = aImageIdx;`);

                        shader.fragmentShader = `
                            varying float vImageIdx;
                            ${shader.fragmentShader}
                        `.replace(
                            `#include <map_fragment>`,
                            `
                            #if defined( USE_MAP )
                                vec2 modifiedUv = vUv;
                                float totalImgs = float(${activeImgObjs.length});
                                modifiedUv.x = (modifiedUv.x + vImageIdx) / totalImgs;
                                vec4 texelColor = texture2D( map, modifiedUv );
                                diffuseColor *= mapTexelToLinear( texelColor );
                            #endif
                            `
                        );
                    };
                }

                this.instancedMesh = new THREE.InstancedMesh(this.baseGeometry, material, this.MAX_INSTANCES);

                if (hasImages && activeImgObjs.length > 1) {
                    const indices = new Float32Array(this.MAX_INSTANCES);
                    for (let i = 0; i < this.MAX_INSTANCES; i++) indices[i] = this.instanceData[i].imageIdx;
                    this.instancedMesh.geometry.setAttribute('aImageIdx', new THREE.InstancedBufferAttribute(indices, 1));
                } else if (!hasImages) {
                    const colors = new Float32Array(this.MAX_INSTANCES * 3);
                    this.instancedMesh.instanceColor = new THREE.InstancedBufferAttribute(colors, 3);
                }

                this.updateKaleidoscope();
                this.scene.add(this.instancedMesh);
            }

            updateKaleidoscope() {
                if (!this.instancedMesh) return;
                this.instancedMesh.count = this.options.count;
                const dummy = new THREE.Object3D();
                const hasImages = this.instancedMesh.material.map !== null;

                for (let i = 0; i < this.options.count; i++) {
                    const data = this.instanceData[i];
                    const sector = i % this.options.symmetry;
                    const baseAngle = (sector / this.options.symmetry) * Math.PI * 2;
                    const r = data.distance * this.options.scatterRadius;

                    dummy.position.set(Math.cos(baseAngle + data.angleOffset * 0.1) * r, Math.sin(baseAngle + data.angleOffset * 0.1) * r, data.z);
                    dummy.rotation.set(data.rotX, data.rotY, baseAngle + Math.PI / 2);

                    const scale = data.baseScale * this.options.scale;
                    dummy.scale.set(scale, scale, scale);
                    dummy.updateMatrix();

                    this.instancedMesh.setMatrixAt(i, dummy.matrix);

                    if (!hasImages) {
                        this.instancedMesh.setColorAt(i, data.color);
                    }
                }

                this.instancedMesh.instanceMatrix.needsUpdate = true;
                if (!hasImages && this.instancedMesh.instanceColor) {
                    this.instancedMesh.instanceColor.needsUpdate = true;
                }
            }

            animate() {
                requestAnimationFrame(this.animate);
                this.time += 0.01;

                if (this.instancedMesh) {
                    const dummy = new THREE.Object3D();

                    for (let i = 0; i < this.options.count; i++) {
                        const data = this.instanceData[i];
                        
                        if (this.options.zoomMode) {
                            data.z += 0.2;
                            if (data.z > 20) data.z = -20;
                        }

                        const sector = i % this.options.symmetry;
                        const baseAngle = (sector / this.options.symmetry) * Math.PI * 2;
                        
                        let currentRadius = data.distance * this.options.scatterRadius;
                        if (this.options.pulseMode) {
                            const pulseWave = Math.sin(this.time * 3.0 + data.distance * 5.0);
                            currentRadius += pulseWave * 2.5;
                        }

                        dummy.position.set(
                            Math.cos(baseAngle + data.angleOffset * 0.1) * currentRadius, 
                            Math.sin(baseAngle + data.angleOffset * 0.1) * currentRadius, 
                            data.z
                        );

                        dummy.rotation.set(
                            this.options.zoomMode ? (data.rotX + (this.time * 3.0)) : data.rotX, 
                            data.rotY, 
                            baseAngle + Math.PI / 2
                        );

                        let currentScale = data.baseScale * this.options.scale;
                        if (this.options.wobbleMode) {
                            currentScale *= (1.0 + Math.sin(this.time * 4.0 + i) * 0.25);
                        }
                        if (this.options.pulseMode) {
                            currentScale *= (1.0 + Math.cos(this.time * 3.0 + data.distance * 3.0) * 0.2);
                        }

                        dummy.scale.set(currentScale, currentScale, currentScale);
                        dummy.updateMatrix();

                        this.instancedMesh.setMatrixAt(i, dummy.matrix);
                    }

                    this.instancedMesh.instanceMatrix.needsUpdate = true;
                    this.instancedMesh.rotation.z += 0.003;

                    this.shinyLight.position.x = Math.sin(this.time * 0.8) * 30;
                    this.shinyLight.position.y = Math.cos(this.time * 0.5) * 30;
                }

                this.renderer.render(this.scene, this.camera);
            }
        }

        const gridContainer = document.getElementById('grid-container');

        let activeInstances = [];
        let globalOptions = {
            count: 1000,
            scale: 1.5,
            symmetry: 8,
            scatterRadius: 12,
            zoomMode: false,
            pulseMode: false,
            wobbleMode: false,
            textureMode: false,
            maskEnabled: false,
            maskUrl: null
        };
        let uploadedImages = [];

        const uiPanel = document.getElementById('ui');
        const uiHeader = document.getElementById('ui-header');
        let isDragging = false, offX = 0, offY = 0;
        uiHeader.addEventListener('mousedown', (e) => {
            isDragging = true; offX = e.clientX - uiPanel.offsetLeft; offY = e.clientY - uiPanel.offsetTop;
            e.preventDefault();
        });
        window.addEventListener('mousemove', (e) => {
            if (!isDragging) return;
            uiPanel.style.left = Math.max(0, Math.min(window.innerWidth - uiPanel.offsetWidth, e.clientX - offX)) + 'px';
            uiPanel.style.top = Math.max(0, Math.min(window.innerHeight - uiPanel.offsetHeight, e.clientY - offY)) + 'px';
        });
        window.addEventListener('mouseup', () => isDragging = false);

        function updateGridLayout(num) {
            if (num === 1) {
                gridContainer.style.gridTemplateColumns = '1fr';
                gridContainer.style.gridTemplateRows = '1fr';
            } else if (num <= 4) {
                gridContainer.style.gridTemplateColumns = 'repeat(2, 1fr)';
                gridContainer.style.gridTemplateRows = 'repeat(2, 1fr)';
            } else if (num <= 6) {
                gridContainer.style.gridTemplateColumns = 'repeat(3, 1fr)';
                gridContainer.style.gridTemplateRows = 'repeat(2, 1fr)';
            } else if (num <= 9) {
                gridContainer.style.gridTemplateColumns = 'repeat(3, 1fr)';
                gridContainer.style.gridTemplateRows = 'repeat(3, 1fr)';
            } else {
                gridContainer.style.gridTemplateColumns = 'repeat(5, 1fr)';
                gridContainer.style.gridTemplateRows = 'repeat(2, 1fr)';
            }
        }

        function renderInstances(targetCount) {
            activeInstances.forEach(item => {
                if (item.kaleidoscope.renderer) item.kaleidoscope.renderer.dispose();
                if (item.kaleidoscope.resizeObserver) item.kaleidoscope.resizeObserver.disconnect();
            });
            activeInstances = [];
            gridContainer.innerHTML = '';

            updateGridLayout(targetCount);

            for (let i = 0; i < targetCount; i++) {
                const wrapper = document.createElement('div');
                wrapper.className = 'kaleidoscope-instance-wrapper';
                
                const container = document.createElement('div');
                container.className = 'canvasMaskContainer';
                wrapper.appendChild(container);
                gridContainer.appendChild(wrapper);

                const kaleidoscope = new ShinyKaleidoscope(container, {
                    count: globalOptions.count,
                    symmetry: globalOptions.symmetry,
                    scale: globalOptions.scale,
                    scatterRadius: globalOptions.scatterRadius,
                    zoomMode: globalOptions.zoomMode,
                    pulseMode: globalOptions.pulseMode,
                    wobbleMode: globalOptions.wobbleMode
                });
                
                const maskCtrl = new MaskController(container, globalOptions.maskUrl, {
                    enabled: globalOptions.maskEnabled
                });

                const randomActive = document.querySelector('.color-btn[data-color="random"]').classList.contains('active');
                const activeCols = [];
                document.querySelectorAll('.color-btn').forEach(b => {
                    if (b.dataset.color !== 'random' && b.classList.contains('active')) activeCols.push(b.dataset.color);
                });

                kaleidoscope.buildInstancedMesh(uploadedImages, randomActive ? 'random' : 'custom', activeCols, globalOptions.textureMode);

                activeInstances.push({ kaleidoscope, maskCtrl });
            }
        }

        document.getElementById('instanceSlider').addEventListener('input', (e) => {
            const val = parseInt(e.target.value);
            document.getElementById('instanceVal').innerText = val;
            renderInstances(val);
        });

        document.getElementById('zoomToggleCheckbox').addEventListener('change', (e) => {
            globalOptions.zoomMode = e.target.checked;
            activeInstances.forEach(inst => inst.kaleidoscope.options.zoomMode = globalOptions.zoomMode);
        });

        document.getElementById('pulseToggleCheckbox').addEventListener('change', (e) => {
            globalOptions.pulseMode = e.target.checked;
            activeInstances.forEach(inst => inst.kaleidoscope.options.pulseMode = globalOptions.pulseMode);
        });

        document.getElementById('wobbleToggleCheckbox').addEventListener('change', (e) => {
            globalOptions.wobbleMode = e.target.checked;
            activeInstances.forEach(inst => inst.kaleidoscope.options.wobbleMode = globalOptions.wobbleMode);
        });

        document.getElementById('textureToggleCheckbox').addEventListener('change', (e) => {
            globalOptions.textureMode = e.target.checked;
            triggerMaterialRebuild();
        });

        document.getElementById('countSlider').addEventListener('input', (e) => {
            globalOptions.count = parseInt(e.target.value);
            document.getElementById('countVal').innerText = globalOptions.count;
            activeInstances.forEach(inst => {
                inst.kaleidoscope.options.count = globalOptions.count;
                inst.kaleidoscope.updateKaleidoscope();
            });
        });

        document.getElementById('scaleSlider').addEventListener('input', (e) => {
            globalOptions.scale = parseFloat(e.target.value);
            document.getElementById('scaleVal').innerText = globalOptions.scale.toFixed(1);
            activeInstances.forEach(inst => {
                inst.kaleidoscope.options.scale = globalOptions.scale;
                inst.kaleidoscope.updateKaleidoscope();
            });
        });

        document.getElementById('symSlider').addEventListener('input', (e) => {
            globalOptions.symmetry = parseInt(e.target.value);
            document.getElementById('symVal').innerText = globalOptions.symmetry;
            activeInstances.forEach(inst => {
                inst.kaleidoscope.options.symmetry = globalOptions.symmetry;
                inst.kaleidoscope.updateKaleidoscope();
            });
        });

        document.getElementById('radiusSlider').addEventListener('input', (e) => {
            globalOptions.scatterRadius = parseInt(e.target.value);
            document.getElementById('radiusVal').innerText = globalOptions.scatterRadius;
            activeInstances.forEach(inst => {
                inst.kaleidoscope.options.scatterRadius = globalOptions.scatterRadius;
                inst.kaleidoscope.updateKaleidoscope();
            });
        });

        document.getElementById('clearBtn').addEventListener('click', () => {
            activeInstances.forEach(inst => inst.kaleidoscope.clearScene());
        });

        const colorButtons = document.querySelectorAll('.color-btn');
        colorButtons.forEach(btn => {
            btn.addEventListener('click', () => {
                const isRand = btn.dataset.color === 'random';
                if (isRand) {
                    colorButtons.forEach(b => b.dataset.color === 'random' ? b.classList.add('active') : b.classList.remove('active'));
                } else {
                    document.querySelector('.color-btn[data-color="random"]').classList.remove('active');
                    btn.classList.toggle('active');
                    if (!Array.from(colorButtons).some(b => b.dataset.color !== 'random' && b.classList.contains('active'))) {
                        document.querySelector('.color-btn[data-color="random"]').classList.add('active');
                    }
                }
                triggerMaterialRebuild();
            });
        });

        const uploadTriggerBtn = document.getElementById('uploadTriggerBtn');
        const imageUploadInput = document.getElementById('imageUploadInput');
        const imageThumbnailsContainer = document.getElementById('imageThumbnails');

        uploadTriggerBtn.addEventListener('click', () => imageUploadInput.click());
        imageUploadInput.addEventListener('change', (e) => {
            const files = Array.from(e.target.files).slice(0, 3 - uploadedImages.length);
            files.forEach(file => {
                const reader = new FileReader();
                reader.onload = (ev) => {
                    const img = new Image();
                    img.onload = () => {
                        if (uploadedImages.length < 3) {
                            uploadedImages.push({ imgElement: img, active: true });
                            updateImageThumbsUI();
                            triggerMaterialRebuild();
                        }
                    };
                    img.src = ev.target.result;
                };
                reader.readAsDataURL(file);
            });
            imageUploadInput.value = '';
        });

        function updateImageThumbsUI() {
            imageThumbnailsContainer.innerHTML = '';
            uploadTriggerBtn.innerText = `Upload Images (${uploadedImages.length}/3)`;
            
            uploadedImages.forEach((item, index) => {
                const wrapper = document.createElement('div');
                wrapper.className = 'thumb-wrapper';

                const thumb = document.createElement('img');
                thumb.src = item.imgElement.src;
                thumb.className = `img-thumb ${item.active ? 'active' : ''}`;
                thumb.addEventListener('click', () => {
                    item.active = !item.active;
                    thumb.classList.toggle('active', item.active);
                    triggerMaterialRebuild();
                });

                const deleteBtn = document.createElement('button');
                deleteBtn.className = 'delete-thumb';
                deleteBtn.innerHTML = 'X';
                deleteBtn.title = 'Remove image';
                deleteBtn.addEventListener('click', (ev) => {
                    ev.stopPropagation();
                    uploadedImages.splice(index, 1);
                    updateImageThumbsUI();
                    triggerMaterialRebuild();
                });

                wrapper.appendChild(thumb);
                wrapper.appendChild(deleteBtn);
                imageThumbnailsContainer.appendChild(wrapper);
            });
        }

        function triggerMaterialRebuild() {
            const randomActive = document.querySelector('.color-btn[data-color="random"]').classList.contains('active');
            const activeCols = [];
            colorButtons.forEach(b => {
                if (b.dataset.color !== 'random' && b.classList.contains('active')) activeCols.push(b.dataset.color);
            });
            activeInstances.forEach(inst => {
                inst.kaleidoscope.buildInstancedMesh(uploadedImages, randomActive ? 'random' : 'custom', activeCols, globalOptions.textureMode);
            });
        }

        document.getElementById('maskToggleCheckbox').addEventListener('change', (e) => {
            globalOptions.maskEnabled = e.target.checked;
            activeInstances.forEach(inst => inst.maskCtrl.setEnabled(globalOptions.maskEnabled));
        });

        const maskThumbnails = document.getElementById('maskThumbnails');
        const maskUploadInput = document.getElementById('maskUploadInput');
        document.getElementById('maskUploadTriggerBtn').addEventListener('click', () => maskUploadInput.click());
        maskUploadInput.addEventListener('change', (e) => {
            const file = e.target.files[0];
            if (!file) return;
            const reader = new FileReader();
            reader.onload = (ev) => {
                const url = ev.target.result;
                globalOptions.maskUrl = url;
                globalOptions.maskEnabled = true;
                document.getElementById('maskToggleCheckbox').checked = true;
                activeInstances.forEach(inst => {
                    inst.maskCtrl.setMaskImage(url);
                    inst.maskCtrl.setEnabled(true);
                });
                const thumb = document.createElement('img');
                thumb.src = url;
                thumb.className = 'img-thumb active';
                document.querySelectorAll('#maskThumbnails .img-thumb').forEach(t => t.classList.remove('active'));
                thumb.addEventListener('click', () => {
                    document.querySelectorAll('#maskThumbnails .img-thumb').forEach(t => t.classList.remove('active'));
                    thumb.classList.add('active');
                    globalOptions.maskUrl = url;
                    activeInstances.forEach(inst => inst.maskCtrl.setMaskImage(url));
                });
                maskThumbnails.appendChild(thumb);
            };
            reader.readAsDataURL(file);
            maskUploadInput.value = '';
        });

        renderInstances(1);
    </script>
</body>
</html>