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>