Show description
AI Image World Explorer (Guided)
AI Image World Explorer (Guided)
Start Exploration
Upload & Explore
Look Left
Go Forward
Look Right
Click to start
Use WASD to move & Mouse to look
AI Image World Explorer (Guided)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Image World Explorer (Guided)</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap');
body {
font-family: 'Inter', sans-serif;
overflow: hidden; /* Prevent scrollbars from appearing */
}
#canvas-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
cursor: pointer;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
#ui-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
padding: 1rem;
display: flex;
flex-direction: column;
align-items: center;
z-index: 10;
pointer-events: none; /* Allow clicks to pass through to the canvas */
}
#controls {
pointer-events: auto; /* Re-enable pointer events for the controls */
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
.loader {
border: 8px solid #f3f3f3;
border-top: 8px solid #3498db;
border-radius: 50%;
width: 60px;
height: 60px;
animation: spin 2s linear infinite;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 30;
display: none; /* Hidden by default */
}
#instructions {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 1.5rem;
text-align: center;
background-color: rgba(0,0,0,0.5);
padding: 1rem 2rem;
border-radius: 0.5rem;
z-index: 20;
pointer-events: none;
}
.action-btn {
color: white;
font-weight: bold;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
transition: background-color 0.3s;
white-space: nowrap;
}
.action-btn:hover:not(:disabled) {
filter: brightness(1.1);
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@keyframes spin {
0% { transform: translate(-50%, -50%) rotate(0deg); }
100% { transform: translate(-50%, -50%) rotate(360deg); }
}
</style>
</head>
<body class="bg-gray-900 text-white">
<div id="canvas-container"></div>
<div id="ui-container">
<div id="controls" class="bg-gray-800 bg-opacity-80 p-4 rounded-lg shadow-lg w-full max-w-4xl">
<div class="flex flex-col sm:flex-row items-center gap-4 w-full">
<input type="text" id="prompt-input" class="w-full bg-gray-700 text-white border border-gray-600 rounded-md px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="e.g., a vibrant alien jungle at night">
<button id="generate-btn" class="action-btn w-full sm:w-auto bg-blue-600">Start Exploration</button>
<button id="upload-btn" class="action-btn w-full sm:w-auto bg-green-600">Upload & Explore</button>
</div>
<div id="direction-controls" class="flex items-center justify-center gap-2 mt-3">
<button id="btn-left" class="action-btn bg-gray-600" disabled>Look Left</button>
<button id="btn-forward" class="action-btn bg-gray-600" disabled>Go Forward</button>
<button id="btn-right" class="action-btn bg-gray-600" disabled>Look Right</button>
</div>
</div>
<p id="status-message" class="mt-2 text-gray-400"></p>
</div>
<div id="loader" class="loader"></div>
<input type="file" id="upload-input" class="hidden" accept="image/*">
<div id="instructions">
<p>Click to start</p>
<p class="text-sm mt-2">Use WASD to move & Mouse to look</p>
</div>
<script type="module">
import { PointerLockControls } from 'https://cdn.skypack.dev/three@0.128.0/examples/jsm/controls/PointerLockControls.js';
let scene, camera, renderer, controls;
let particles;
let isGenerating = false;
let currentPrompt = "";
let lastImageBase64 = null;
const moveState = { forward: false, backward: false, left: false, right: false };
const moveSpeed = 20.0;
const clock = new THREE.Clock();
const canvasContainer = document.getElementById('canvas-container');
const promptInput = document.getElementById('prompt-input');
const generateBtn = document.getElementById('generate-btn');
const uploadBtn = document.getElementById('upload-btn');
const uploadInput = document.getElementById('upload-input');
const loader = document.getElementById('loader');
const statusMessage = document.getElementById('status-message');
const instructions = document.getElementById('instructions');
const btnLeft = document.getElementById('btn-left');
const btnForward = document.getElementById('btn-forward');
const btnRight = document.getElementById('btn-right');
function init() {
scene = new THREE.Scene();
scene.background = new THREE.Color(0x111827);
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 2000);
camera.position.set(0, 50, 250);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
canvasContainer.appendChild(renderer.domElement);
controls = new PointerLockControls(camera, renderer.domElement);
scene.add(controls.getObject());
promptInput.value = "A bioluminescent forest on an alien planet, glowing mushrooms and strange flora";
currentPrompt = promptInput.value;
addEventListeners();
animate();
}
function addEventListeners() {
canvasContainer.addEventListener('click', () => controls.lock());
controls.addEventListener('lock', () => { instructions.style.display = 'none'; });
controls.addEventListener('unlock', () => { instructions.style.display = 'block'; });
window.addEventListener('resize', onWindowResize, false);
document.addEventListener('keydown', onKeyDown, false);
document.addEventListener('keyup', onKeyUp, false);
generateBtn.addEventListener('click', () => handleExploration('initial'));
uploadBtn.addEventListener('click', () => uploadInput.click());
uploadInput.addEventListener('change', handleFileUpload);
btnLeft.addEventListener('click', () => handleExploration('left'));
btnForward.addEventListener('click', () => handleExploration('forward'));
btnRight.addEventListener('click', () => handleExploration('right'));
}
async function handleFileUpload(event) {
const file = event.target.files[0];
if (!file || isGenerating) return;
setUIState(true);
statusMessage.textContent = "Processing uploaded image...";
const reader = new FileReader();
reader.onload = async (e) => {
try {
const fullDataUrl = e.target.result;
const base64Data = fullDataUrl.split(',')[1];
lastImageBase64 = base64Data;
await createParticleWorld(fullDataUrl);
statusMessage.textContent = "World created. Asking AI for a description...";
currentPrompt = await describeImage(base64Data);
promptInput.value = currentPrompt;
statusMessage.textContent = "Ready to explore! Choose your direction.";
} catch (error) {
console.error("Error processing upload:", error);
statusMessage.textContent = "Failed to process uploaded image.";
} finally {
setUIState(false);
uploadInput.value = '';
}
};
reader.readAsDataURL(file);
}
async function handleExploration(direction) {
if (isGenerating) return;
setUIState(true);
try {
if (direction === 'initial') {
currentPrompt = promptInput.value;
lastImageBase64 = null;
statusMessage.textContent = "Generating initial world...";
} else {
statusMessage.textContent = `Imagining the scene to the ${direction}...`;
await evolvePrompt(direction);
}
promptInput.value = currentPrompt;
const imageData = await generateImageWithRetry(currentPrompt, lastImageBase64);
lastImageBase64 = imageData.base64;
statusMessage.textContent = "Image generated. Creating 3D particle world...";
await createParticleWorld(`data:image/png;base64,${lastImageBase64}`);
statusMessage.textContent = "World updated! Choose your next direction.";
} catch (error) {
console.error('Error during generation cycle:', error);
statusMessage.textContent = "Failed to generate world. Check console.";
} finally {
setUIState(false);
}
}
function setUIState(generating) {
isGenerating = generating;
loader.style.display = generating ? 'block' : 'none';
generateBtn.disabled = generating;
uploadBtn.disabled = generating;
const directionButtonsDisabled = generating || !lastImageBase64;
btnLeft.disabled = directionButtonsDisabled;
btnForward.disabled = directionButtonsDisabled;
btnRight.disabled = directionButtonsDisabled;
}
async function describeImage(base64ImageData) {
const prompt = "Describe this image in a creative and descriptive way, as if it were a scene in a virtual world. Be concise.";
const parts = [{ text: prompt }, { inlineData: { mimeType: "image/png", data: base64ImageData } }];
const payload = { contents: [{ role: "user", parts: parts }] };
const apiKey = "";
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-05-20:generateContent?key=${apiKey}`;
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const result = await response.json();
if (result.candidates?.[0]?.content?.parts?.[0]?.text) {
return result.candidates[0].content.parts[0].text.trim();
}
} catch (error) {
console.error("Error describing image:", error);
}
return "An uploaded custom scene."; // Fallback description
}
async function evolvePrompt(direction) {
const directionPhrase = {
left: "if they turned to look to their left",
right: "if they turned to look to their right",
forward: "if they took a few steps forward"
};
const metaPrompt = `You are a creative world-building assistant. The user is exploring a virtual world. The current scene is: "${currentPrompt}". Describe what they would see ${directionPhrase[direction]}. Keep the style and key elements consistent with the previous scene. Be descriptive, imaginative, and concise. Only provide the description of the new scene.`;
const payload = { contents: [{ role: "user", parts: [{ text: metaPrompt }] }] };
const apiKey = "";
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-05-20:generateContent?key=${apiKey}`;
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const result = await response.json();
if (result.candidates?.[0]?.content?.parts?.[0]?.text) {
currentPrompt = result.candidates[0].content.parts[0].text.trim();
} else {
console.warn("Could not evolve prompt, reusing the old one.");
}
} catch (error) {
console.error("Error evolving prompt:", error);
}
}
async function generateImageWithRetry(prompt, base64ImageData = null) {
const parts = [{ text: prompt }];
if (base64ImageData) {
parts.push({ inlineData: { mimeType: "image/png", data: base64ImageData } });
}
const payload = {
contents: [{ role: "user", parts: parts }],
generationConfig: { responseModalities: ['TEXT', 'IMAGE'] },
};
const apiKey = "";
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-preview-image-generation:generateContent?key=${apiKey}`;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const result = await response.json();
const imagePart = result?.candidates?.[0]?.content?.parts?.find(p => p.inlineData);
if (imagePart?.inlineData?.data) {
return { base64: imagePart.inlineData.data };
} else {
throw new Error("Invalid image response from API.");
}
} catch (error) {
console.warn(`Image generation attempt ${attempt + 1} failed. Retrying...`, error);
if (attempt >= 2) throw error;
await new Promise(res => setTimeout(res, 1000 * Math.pow(2, attempt)));
}
}
}
function createParticleWorld(imageUrl) {
return new Promise((resolve, reject) => {
if (particles) {
scene.remove(particles);
particles.geometry.dispose();
particles.material.dispose();
}
const image = new Image();
image.crossOrigin = "Anonymous";
image.src = imageUrl;
image.onload = () => {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
const imgWidth = image.width;
const imgHeight = image.height;
canvas.width = imgWidth;
canvas.height = imgHeight;
context.drawImage(image, 0, 0);
const imageData = context.getImageData(0, 0, imgWidth, imgHeight).data;
const geometry = new THREE.BufferGeometry();
const positions = [];
const colors = [];
const particleDensity = 0.5;
const depthFactor = 200;
for (let y = 0; y < imgHeight; y++) {
for (let x = 0; x < imgWidth; x++) {
if (Math.random() > particleDensity) continue;
const i = (y * imgWidth + x) * 4;
const r = imageData[i] / 255;
const g = imageData[i + 1] / 255;
const b = imageData[i + 2] / 255;
const brightness = (r + g + b) / 3;
const z = (brightness - 0.5) * depthFactor;
positions.push(x - imgWidth / 2, -y + imgHeight / 2, z);
colors.push(r, g, b);
}
}
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
geometry.computeBoundingSphere();
const material = new THREE.PointsMaterial({
size: 1.5,
vertexColors: true,
sizeAttenuation: true
});
particles = new THREE.Points(geometry, material);
scene.add(particles);
resolve();
};
image.onerror = (err) => {
console.error("Error loading image for particle world:", err);
reject(err);
}
});
}
function onKeyDown(event) {
switch (event.code) {
case 'KeyW': case 'ArrowUp': moveState.forward = true; break;
case 'KeyS': case 'ArrowDown': moveState.backward = true; break;
case 'KeyA': case 'ArrowLeft': moveState.left = true; break;
case 'KeyD': case 'ArrowRight': moveState.right = true; break;
}
}
function onKeyUp(event) {
switch (event.code) {
case 'KeyW': case 'ArrowUp': moveState.forward = false; break;
case 'KeyS': case 'ArrowDown': moveState.backward = false; break;
case 'KeyA': case 'ArrowLeft': moveState.left = false; break;
case 'KeyD': case 'ArrowRight': moveState.right = false; break;
}
}
function updateMovement(delta) {
if (!controls.isLocked) return;
const direction = new THREE.Vector3();
direction.z = Number(moveState.forward) - Number(moveState.backward);
direction.x = Number(moveState.right) - Number(moveState.left);
direction.normalize();
if (moveState.forward || moveState.backward) controls.moveForward(direction.z * moveSpeed * delta);
if (moveState.left || moveState.right) controls.moveRight(direction.x * moveSpeed * delta);
}
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
updateMovement(delta);
renderer.render(scene, camera);
}
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
init();
</script>
</body>
</html>