Qwen3.8 Flash
by QwenReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 7 min 38 s
- Cost
- $0.016
- Output tokens
- 34,256
- Spent thinking
- 24,906 tokens
- Speed
- 74.9 tok/s
- Console errors
- 0
- First token after
- 597 ms
- First visible text after
- 6 min 12 s
- Prompt tokens
- 629
- Code size
- 23.4 KB
- Finish reason
- stop
- Served by
- Alibaba
- Model id
qwen/qwen3.8-flash- Reasoning sent
model default- Max output
- 64,000 tokens
- Generated
- Nov 11, 2026
What it was asked
The prompt
Build a low-poly 3D scene of a campfire in a forest clearing at night, with Three.js in a single HTML file. Load Three.js only through this import map (do not use any other URL or version): <script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"}}</script> Requirements: - A ground with a few low-poly rocks and logs around a campfire in the center. - At least 30 low-poly trees placed randomly around the clearing (not on the fire). - The campfire has animated flames and a warm point light that flickers and casts shadows. - Sparks rising from the fire as a simple particle system. - A dark night sky with stars and a moon, plus fog that fades the distant trees. - OrbitControls to look around, with limits so the camera stays above the ground. - The renderer fills the window and handles resizing correctly.
System prompt, sent with every prompt
You are generating a self-contained web demo. It will be rendered automatically in a sandboxed iframe and compared side by side with the output of other AI models given the exact same task. Output rules: - Reply with exactly ONE fenced code block containing the complete file: ```html (or ```svg when the task asks for an SVG). No text before or after the code block. - Everything must be inline: CSS in <style>, JavaScript in <script>. No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task. - Do not use localStorage, sessionStorage, cookies, IndexedDB, alert(), confirm(), prompt() or window.open(): they are blocked in the sandbox and will throw. - The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar. - Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction. - Everything that makes the demo interesting must be visible within the first seconds: nothing hidden behind a high score, a later level or a long play session. - Games must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" message, so the game can be judged in action without playing. - The iframe only receives keyboard events after the user clicks it: if the demo uses the keyboard, display a clear "Click or press a key to start" message. - The code must run with zero errors in the console of the latest Chrome.
Code run-1.html · 837 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Low-Poly Campfire at Night</title>
<style>
* { box-sizing: border-box; }
html, body {
height: 100%;
margin: 0;
overflow: hidden;
background: #03050c;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
canvas { display: block; }
.panel {
position: absolute;
top: 18px;
left: 18px;
z-index: 10;
color: #dce9ff;
background: rgba(6, 13, 26, 0.55);
border: 1px solid rgba(125, 165, 255, 0.18);
border-radius: 16px;
padding: 12px 15px;
max-width: min(360px, calc(100vw - 36px));
pointer-events: none;
backdrop-filter: blur(9px);
box-shadow: 0 12px 35px rgba(0,0,0,0.28);
}
.panel h1 {
margin: 0;
font-size: clamp(15px, 2.2vw, 19px);
font-weight: 650;
letter-spacing: 0.02em;
color: #f3f8ff;
}
.panel p {
margin: 5px 0 0;
font-size: clamp(11px, 1.5vw, 13px);
opacity: 0.78;
line-height: 1.35;
}
#hint {
position: absolute;
left: 50%;
bottom: 18px;
transform: translateX(-50%);
z-index: 10;
color: #e9f1ff;
background: rgba(10, 16, 28, 0.62);
border: 1px solid rgba(150, 180, 255, 0.16);
border-radius: 999px;
padding: 9px 16px;
font-size: clamp(11px, 1.6vw, 13px);
pointer-events: none;
backdrop-filter: blur(8px);
white-space: nowrap;
animation: hintFade 1.1s ease 7.6s forwards;
}
@keyframes hintFade {
to { opacity: 0; transform: translateX(-50%) translateY(6px); }
}
@media (max-width: 620px) {
#hint { white-space: normal; width: min(90vw, 420px); text-align: center; }
.panel { right: 18px; max-width: none; }
}
</style>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
}
}
</script>
</head>
<body>
<div class="panel">
<h1>Low-Poly Campfire</h1>
<p>A quiet forest clearing after dark. Orbit around the fire, listen to the crackle of sparks.</p>
</div>
<div id="hint">Click or drag to orbit · scroll to zoom · auto-orbit demo</div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// Renderer
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.03;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
// Scene
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x04070f);
scene.fog = new THREE.FogExp2(0x070f1d, 0.016);
// Camera + controls
const camera = new THREE.PerspectiveCamera(56, window.innerWidth / window.innerHeight, 0.1, 1200);
camera.position.set(9.5, 6.2, 8.0);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1.05, 0);
controls.enableDamping = true;
controls.dampingFactor = 0.07;
controls.enablePan = false;
controls.minDistance = 5.5;
controls.maxDistance = 58;
controls.minPolarAngle = 0.06;
controls.maxPolarAngle = Math.PI * 0.485;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.55;
controls.update();
let idleTimer;
controls.addEventListener('start', () => {
controls.autoRotate = false;
clearTimeout(idleTimer);
});
controls.addEventListener('end', () => {
clearTimeout(idleTimer);
idleTimer = setTimeout(() => { controls.autoRotate = true; }, 4500);
});
// Helpers
function rand(a, b) { return a + Math.random() * (b - a); }
function pick(arr) { return arr[(Math.random() * arr.length) | 0]; }
function noiseHeight(x, z) {
return (
Math.sin(x * 0.13) * Math.cos(z * 0.17) * 0.78 +
Math.sin(x * 0.06 + z * 0.11) * 0.52 +
Math.cos(x * 0.22 - z * 0.09) * 0.31
);
}
function groundY(x, z) {
const r = Math.hypot(x, z);
const blend = THREE.MathUtils.clamp((r - 5.5) / 6.5, 0, 1);
return noiseHeight(x, z) * blend * 0.85;
}
// Ground
const groundGeo = new THREE.PlaneGeometry(150, 150, 82, 82);
groundGeo.rotateX(-Math.PI / 2);
const groundPos = groundGeo.attributes.position;
const groundCol = new THREE.BufferAttribute(new Float32Array(groundPos.count * 3), 3);
const tempColor = new THREE.Color();
const warmColor = new THREE.Color(0x302017);
for (let i = 0; i < groundPos.count; i++) {
const x = groundPos.getX(i);
const z = groundPos.getZ(i);
const y = groundY(x, z);
groundPos.setY(i, y);
const r = Math.hypot(x, z);
const hue = 0.285 + Math.sin(x * 0.07 + z * 0.11) * 0.035;
const sat = THREE.MathUtils.clamp(0.33 + Math.cos(x * 0.13 - z * 0.18) * 0.09, 0.08, 0.72);
const light = THREE.MathUtils.clamp(0.082 + y * 0.006 + Math.sin(x * 0.35 + z * 0.27) * 0.006, 0.02, 0.16);
tempColor.setHSL(hue, sat, light);
tempColor.lerp(warmColor, Math.max(0, 1 - r / 9) * 0.34);
groundCol.setXYZ(i, tempColor.r, tempColor.g, tempColor.b);
}
groundGeo.setAttribute('color', groundCol);
groundGeo.computeVertexNormals();
const ground = new THREE.Mesh(
groundGeo,
new THREE.MeshStandardMaterial({
vertexColors: true,
roughness: 1,
metalness: 0,
flatShading: true
})
);
ground.receiveShadow = true;
scene.add(ground);
// Shared geometries and materials
const trunkGeo = new THREE.CylinderGeometry(0.17, 0.28, 1.55, 5, 1);
trunkGeo.translate(0, 0.775, 0);
const foliageGeo = new THREE.ConeGeometry(1, 1, 6, 1);
foliageGeo.translate(0, 0.5, 0);
const rockGeo = new THREE.DodecahedronGeometry(0.45, 0);
const logGeo = new THREE.CylinderGeometry(0.22, 0.25, 2.3, 6, 1);
logGeo.rotateZ(Math.PI / 2);
const stickGeo = new THREE.CylinderGeometry(0.08, 0.11, 1.15, 5, 1);
stickGeo.rotateZ(Math.PI / 2);
const trunkBase = new THREE.MeshStandardMaterial({
color: 0x3d2a1e,
flatShading: true,
roughness: 1,
metalness: 0
});
const trunkMats = [];
for (let i = 0; i < 6; i++) {
const m = trunkBase.clone();
m.color.offsetHSL(rand(-0.02, 0.02), rand(-0.03, 0.05), rand(-0.03, 0.04));
trunkMats.push(m);
}
const foliageBase = new THREE.MeshStandardMaterial({
color: 0x1d3b28,
flatShading: true,
roughness: 0.95,
metalness: 0
});
const foliageMats = [];
for (let i = 0; i < 10; i++) {
const m = foliageBase.clone();
m.color.offsetHSL(rand(-0.045, 0.035), rand(-0.08, 0.10), rand(-0.03, 0.035));
foliageMats.push(m);
}
const rockMats = [];
for (let i = 0; i < 7; i++) {
const c = new THREE.Color().setHSL(rand(0.58, 0.66), rand(0.08, 0.18), rand(0.20, 0.32));
rockMats.push(new THREE.MeshStandardMaterial({
color: c,
flatShading: true,
roughness: 0.92,
metalness: 0
}));
}
const logMats = [];
for (let i = 0; i < 5; i++) {
const c = new THREE.Color().setHSL(rand(0.07, 0.12), rand(0.28, 0.45), rand(0.14, 0.22));
logMats.push(new THREE.MeshStandardMaterial({
color: c,
flatShading: true,
roughness: 0.95,
metalness: 0
}));
}
const charredMats = [];
for (let i = 0; i < 3; i++) {
const c = new THREE.Color().setHSL(rand(0.05, 0.09), rand(0.18, 0.35), rand(0.08, 0.14));
charredMats.push(new THREE.MeshStandardMaterial({
color: c,
flatShading: true,
roughness: 1,
metalness: 0,
emissive: new THREE.Color(0x1a0702),
emissiveIntensity: 0.35
}));
}
// Trees
const treePositions = [];
function makeTree() {
const group = new THREE.Group();
const trunk = new THREE.Mesh(trunkGeo, pick(trunkMats));
trunk.castShadow = true;
trunk.receiveShadow = true;
group.add(trunk);
const leafCount = 3 + ((Math.random() * 2) | 0);
const trunkTop = 1.25;
for (let i = 0; i < leafCount; i++) {
const cone = new THREE.Mesh(foliageGeo, pick(foliageMats));
const size = 1.22 - i * 0.26 + rand(-0.08, 0.12);
const height = 1.15 + rand(-0.08, 0.32);
cone.scale.set(Math.max(0.35, size), height, Math.max(0.35, size));
cone.position.y = trunkTop + i * 0.68 + rand(-0.05, 0.05);
cone.rotation.y = Math.random() * Math.PI * 2;
cone.castShadow = true;
cone.receiveShadow = true;
group.add(cone);
}
const s = 0.82 + rand(0, 1.15);
group.scale.set(s, s * (0.82 + rand(0, 0.62)), s);
return group;
}
function tryAddTree(requiredCount, totalTarget) {
for (let attempt = 0; attempt < 30; attempt++) {
const angle = Math.random() * Math.PI * 2;
const radius = Math.sqrt(rand(0, 1)) * 44 + 8.5;
const x = Math.cos(angle) * radius;
const z = Math.sin(angle) * radius;
let ok = true;
for (const p of treePositions) {
const dx = x - p.x;
const dz = z - p.z;
if (dx * dx + dz * dz < 11.5) {
ok = false;
break;
}
}
if (ok || requiredCount > 25) {
treePositions.push({ x, z });
const tree = makeTree();
tree.position.set(x, groundY(x, z) - 0.07, z);
scene.add(tree);
return true;
}
}
return false;
}
for (let i = 0; i < 54; i++) {
tryAddTree(i, 54);
}
// Rocks
function addRock(x, z, size, yOverride) {
const rock = new THREE.Mesh(rockGeo, pick(rockMats));
const sx = size * rand(0.75, 1.45);
const sy = size * rand(0.45, 0.95);
const sz = size * rand(0.75, 1.45);
rock.scale.set(sx, sy, sz);
rock.position.set(x, (yOverride ?? groundY(x, z)) + sy * 0.15, z);
rock.rotation.set(rand(-0.3, 0.3), rand(0, Math.PI * 2), rand(-0.2, 0.2));
rock.castShadow = true;
rock.receiveShadow = true;
scene.add(rock);
}
// Fire pit stones
for (let i = 0; i < 14; i++) {
const angle = (i / 14) * Math.PI * 2 + rand(-0.12, 0.12);
const r = 1.72 + rand(-0.22, 0.48);
const x = Math.cos(angle) * r;
const z = Math.sin(angle) * r;
addRock(x, z, rand(0.20, 0.36), 0);
}
// Scattered rocks
for (let i = 0; i < 26; i++) {
const angle = Math.random() * Math.PI * 2;
const r = rand(3.6, 38);
const x = Math.cos(angle) * r;
const z = Math.sin(angle) * r;
addRock(x, z, rand(0.18, 0.58));
}
// Logs
function addLog(x, z, angle, len = 2.3, radius = 0.24, y = null, materials = logMats) {
const log = new THREE.Mesh(logGeo, pick(materials));
log.scale.set(len / 2.3, radius / 0.24, radius / 0.24);
log.position.set(x, y === null ? groundY(x, z) + radius * 0.93 : y, z);
log.rotation.y = angle;
log.castShadow = true;
log.receiveShadow = true;
scene.add(log);
}
// Seat logs around the fire
for (let i = 0; i < 5; i++) {
const angle = (i / 5) * Math.PI * 2;
const r = 3.05 + rand(-0.25, 0.65);
const x = Math.cos(angle) * r;
const z = Math.sin(angle) * r;
addLog(x, z, angle + Math.PI / 2 + rand(-0.42, 0.42), rand(1.9, 2.7), rand(0.22, 0.29), 0);
}
// Some fallen logs in the clearing
for (let i = 0; i < 6; i++) {
const angle = Math.random() * Math.PI * 2;
const r = rand(6, 24);
const x = Math.cos(angle) * r;
const z = Math.sin(angle) * r;
addLog(x, z, Math.random() * Math.PI * 2, rand(1.5, 2.8), rand(0.17, 0.27));
}
// Campfire
const fireGroup = new THREE.Group();
scene.add(fireGroup);
// Criss-cross sticks
for (let i = 0; i < 6; i++) {
const angle = rand(0, Math.PI * 2);
const len = rand(0.85, 1.25);
const x = Math.cos(angle) * rand(0.02, 0.36);
const z = Math.sin(angle) * rand(0.02, 0.36);
const stick = new THREE.Mesh(stickGeo, pick(charredMats));
stick.scale.set(len / 1.15, rand(0.85, 1.35), rand(0.85, 1.35));
stick.position.set(x, 0.10 + rand(0, 0.16), z);
stick.rotation.y = angle + rand(-0.35, 0.35);
stick.rotation.z = rand(-0.18, 0.18);
stick.castShadow = true;
stick.receiveShadow = true;
fireGroup.add(stick);
}
// Ember core
const ember = new THREE.Mesh(
new THREE.IcosahedronGeometry(0.27, 0),
new THREE.MeshBasicMaterial({
color: 0xff3b11,
transparent: true,
opacity: 0.58,
blending: THREE.AdditiveBlending,
depthWrite: false,
fog: false
})
);
ember.position.set(0, 0.26, 0);
ember.renderOrder = 1;
fireGroup.add(ember);
// Animated low-poly flame shells
const flames = [];
const flameGroup = new THREE.Group();
flameGroup.position.y = 0.16;
fireGroup.add(flameGroup);
function addFlame(radius, height, color, opacity, sway, lift, phase, x = 0, y = 0.0, z = 0) {
let geo = new THREE.ConeGeometry(radius, height, 6, 3, true);
geo.translate(0, height / 2, 0);
geo = geo.toNonIndexed();
geo.deleteAttribute('normal');
const base = new Float32Array(geo.attributes.position.array);
const mat = new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity,
blending: THREE.AdditiveBlending,
depthWrite: false,
side: THREE.DoubleSide,
fog: false
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(x, y, z);
mesh.renderOrder = 2;
mesh.frustumCulled = false;
mesh.userData = {
base,
height,
sway,
lift,
phase,
speed: rand(0.9, 1.6),
baseOpacity: opacity
};
flameGroup.add(mesh);
flames.push(mesh);
}
// Main flame stack
addFlame(0.46, 1.52, 0xff5522, 0.26, 0.14, 0.08, 0.0);
addFlame(0.36, 1.26, 0xff8d34, 0.38, 0.11, 0.06, 1.25);
addFlame(0.25, 1.00, 0xffce74, 0.56, 0.08, 0.05, 2.45);
addFlame(0.14, 0.72, 0xfff5d1, 0.76, 0.05, 0.035, 3.85);
// Little side tongues
const tongueOffsets = [
[-0.26, 0.16, 0.07],
[0.24, 0.14, -0.08],
[0.02, 0.18, 0.26],
[0.05, 0.13, -0.25]
];
for (let i = 0; i < tongueOffsets.length; i++) {
const [ox, oy, oz] = tongueOffsets[i];
addFlame(0.18, rand(0.62, 0.82), 0xff7828, 0.30, 0.10, 0.05, i * 1.7 + 5, ox, oy, oz);
}
function updateFlame(mesh, t) {
const pos = mesh.geometry.attributes.position;
const arr = pos.array;
const base = mesh.userData.base;
const h = mesh.userData.height;
const sway = mesh.userData.sway;
const lift = mesh.userData.lift;
const phase = mesh.userData.phase;
const speed = mesh.userData.speed;
const tt = t * speed;
for (let i = 0; i < pos.count; i++) {
const i3 = i * 3;
const x0 = base[i3];
const y0 = base[i3 + 1];
const z0 = base[i3 + 2];
const hf = THREE.MathUtils.clamp(y0 / h, 0, 1);
const amp = sway * (0.18 + hf);
arr[i3] = x0 +
Math.sin(tt * 8.2 + phase + y0 * 4.1 + z0 * 2.0) * amp +
Math.sin(tt * 15.1 + phase * 1.7 + y0 * 5.6) * amp * 0.34;
arr[i3 + 1] = y0 +
Math.sin(tt * 10.6 + phase * 0.9 + x0 * 5.2 + z0 * 3.8) * lift * hf;
arr[i3 + 2] = z0 +
Math.cos(tt * 9.1 + phase * 1.2 + y0 * 3.7 + x0 * 2.4) * amp +
Math.cos(tt * 17.2 + phase * 2.1 + y0 * 4.9) * amp * 0.34;
}
pos.needsUpdate = true;
const pulseX = 0.93 + 0.07 * Math.sin(tt * 11.8 + phase);
const pulseY = 0.90 + 0.16 * Math.sin(tt * 14.3 + phase * 2.3);
const pulseZ = 0.93 + 0.07 * Math.cos(tt * 10.2 + phase * 1.4);
mesh.scale.set(pulseX, pulseY, pulseZ);
mesh.rotation.y = Math.sin(t * 0.9 + phase) * 0.42;
}
// Warm flickering point light with shadows
const baseLightIntensity = 84;
const fireLight = new THREE.PointLight(0xff9a55, baseLightIntensity, 36, 2);
fireLight.position.set(0, 1.05, 0);
fireLight.castShadow = true;
fireLight.shadow.mapSize.set(1024, 1024);
fireLight.shadow.camera.near = 0.45;
fireLight.shadow.camera.far = 30;
fireLight.shadow.bias = -0.001;
fireLight.shadow.normalBias = 0.02;
scene.add(fireLight);
// Soft glow
const glow = new THREE.Mesh(
new THREE.SphereGeometry(1.25, 16, 10),
new THREE.MeshBasicMaterial({
color: 0xff6b1d,
transparent: true,
opacity: 0.075,
blending: THREE.AdditiveBlending,
depthWrite: false,
fog: false
})
);
glow.position.set(0, 0.8, 0);
glow.renderOrder = 1;
scene.add(glow);
// Sparks particle system
class SparkSystem {
constructor(count) {
this.count = count;
this.positions = new Float32Array(count * 3);
this.velocities = new Float32Array(count * 3);
this.colors = new Float32Array(count * 3);
this.baseColors = new Float32Array(count * 3);
this.life = new Float32Array(count);
this.maxLife = new Float32Array(count);
this.budget = count * 0.45;
this.tmpColor = new THREE.Color();
for (let i = 0; i < count; i++) {
this.positions[i * 3 + 1] = -100;
this.life[i] = 0;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(this.positions, 3).setUsage(THREE.DynamicDrawUsage));
geometry.setAttribute('color', new THREE.BufferAttribute(this.colors, 3).setUsage(THREE.DynamicDrawUsage));
const material = new THREE.PointsMaterial({
size: 0.115,
vertexColors: true,
transparent: true,
opacity: 1,
depthWrite: false,
blending: THREE.AdditiveBlending,
sizeAttenuation: true,
fog: false
});
this.points = new THREE.Points(geometry, material);
this.points.frustumCulled = false;
this.points.renderOrder = 3;
scene.add(this.points);
}
respawn(i) {
const ix = i * 3;
const angle = Math.random() * Math.PI * 2;
const r = Math.random() * 0.3;
this.positions[ix] = Math.cos(angle) * r + rand(-0.08, 0.08);
this.positions[ix + 1] = rand(0.52, 1.05);
this.positions[ix + 2] = Math.sin(angle) * r + rand(-0.08, 0.08);
const spread = rand(0.18, 0.55);
this.velocities[ix] = Math.cos(angle) * spread + rand(-0.22, 0.22);
this.velocities[ix + 1] = rand(1.35, 2.75);
this.velocities[ix + 2] = Math.sin(angle) * spread + rand(-0.22, 0.22);
const l = rand(0.7, 2.25);
this.life[i] = l;
this.maxLife[i] = l;
const hue = rand(0.045, 0.13);
const sat = rand(0.75, 1.0);
const light = rand(0.52, 0.78);
this.tmpColor.setHSL(hue, sat, light);
this.baseColors[ix] = this.tmpColor.r;
this.baseColors[ix + 1] = this.tmpColor.g;
this.baseColors[ix + 2] = this.tmpColor.b;
this.colors[ix] = this.tmpColor.r;
this.colors[ix + 1] = this.tmpColor.g;
this.colors[ix + 2] = this.tmpColor.b;
}
update(dt, time, flicker) {
this.budget = Math.min(this.budget + dt * (36 * flicker), 26);
for (let i = 0; i < this.count; i++) {
const ix = i * 3;
if (this.life[i] <= 0) {
this.positions[ix + 1] = -100;
this.colors[ix] = 0;
this.colors[ix + 1] = 0;
this.colors[ix + 2] = 0;
if (this.budget >= 1) {
this.respawn(i);
this.budget -= 1;
}
continue;
}
this.positions[ix] += this.velocities[ix] * dt;
this.positions[ix + 1] += this.velocities[ix + 1] * dt;
this.positions[ix + 2] += this.velocities[ix + 2] * dt;
this.velocities[ix] += Math.sin(time * 5.2 + i * 0.31) * 0.55 * dt;
this.velocities[ix + 1] -= 1.25 * dt;
this.velocities[ix + 2] += Math.cos(time * 4.4 + i * 0.27) * 0.55 * dt;
this.life[i] -= dt;
const ratio = Math.max(0, this.life[i] / this.maxLife[i]);
const fade = ratio * ratio;
this.colors[ix] = this.baseColors[ix] * fade;
this.colors[ix + 1] = this.baseColors[ix + 1] * (0.45 + ratio * 0.55);
this.colors[ix + 2] = this.baseColors[ix + 2] * (0.30 + ratio * 0.70);
}
this.points.geometry.attributes.position.needsUpdate = true;
this.points.geometry.attributes.color.needsUpdate = true;
}
}
const sparks = new SparkSystem(220);
// Sky: stars
const starCount = 1400;
const starPositions = new Float32Array(starCount * 3);
const starColors = new Float32Array(starCount * 3);
const starColor = new THREE.Color();
for (let i = 0; i < starCount; i++) {
let x = rand(-1, 1);
let y = rand(-1, 1);
let z = rand(-1, 1);
const len = Math.hypot(x, y, z) || 1;
x /= len;
y /= len;
z /= len;
if (y < -0.08) y = Math.abs(y) * 0.78;
const r = rand(270, 430);
starPositions[i * 3] = x * r;
starPositions[i * 3 + 1] = y * r;
starPositions[i * 3 + 2] = z * r;
const hue = Math.random() < 0.18 ? rand(0.56, 0.64) : rand(0.10, 0.20);
const sat = rand(0.05, 0.7);
const light = rand(0.45, 0.92);
starColor.setHSL(hue, sat, light);
const brightness = rand(0.35, 1.0);
starColors[i * 3] = starColor.r * brightness;
starColors[i * 3 + 1] = starColor.g * brightness;
starColors[i * 3 + 2] = starColor.b * brightness;
}
const starGeo = new THREE.BufferGeometry();
starGeo.setAttribute('position', new THREE.BufferAttribute(starPositions, 3));
starGeo.setAttribute('color', new THREE.BufferAttribute(starColors, 3));
starGeo.computeBoundingSphere();
const stars = new THREE.Points(starGeo, new THREE.PointsMaterial({
size: 1.35,
sizeAttenuation: false,
vertexColors: true,
transparent: true,
opacity: 0.92,
depthWrite: false,
blending: THREE.AdditiveBlending,
fog: false
}));
stars.frustumCulled = false;
scene.add(stars);
// Moon
const moonDir = new THREE.Vector3(110, 92, -150).normalize();
const moon = new THREE.Mesh(
new THREE.SphereGeometry(9, 18, 12),
new THREE.MeshBasicMaterial({
color: 0xddeeff,
transparent: true,
opacity: 0.94,
fog: false
})
);
moon.position.copy(moonDir).multiplyScalar(360);
scene.add(moon);
const moonHalo = new THREE.Mesh(
new THREE.SphereGeometry(15, 18, 12),
new THREE.MeshBasicMaterial({
color: 0x6b8cc9,
transparent: true,
opacity: 0.11,
depthWrite: false,
blending: THREE.AdditiveBlending,
fog: false
})
);
moonHalo.position.copy(moon.position);
scene.add(moonHalo);
// Night lights
const ambient = new THREE.AmbientLight(0x121928, 0.35);
scene.add(ambient);
const hemi = new THREE.HemisphereLight(0x1d3259, 0x100a07, 0.32);
scene.add(hemi);
const moonLight = new THREE.DirectionalLight(0x668bc2, 0.26);
moonLight.position.copy(moonDir).multiplyScalar(55);
moonLight.target.position.set(0, 0, 0);
scene.add(moonLight);
scene.add(moonLight.target);
// Hint interaction
const hint = document.getElementById('hint');
function hideHint() {
if (!hint) return;
hint.style.animation = 'none';
hint.style.opacity = '0';
}
window.addEventListener('pointerdown', hideHint, { once: true });
window.addEventListener('keydown', hideHint, { once: true });
// Resize
function onResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
}
window.addEventListener('resize', onResize);
// Animation
const clock = new THREE.Clock();
let time = 0;
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.033);
time += dt;
// Flicker
let flicker =
0.84 +
0.16 * Math.sin(time * 12.1) +
0.11 * Math.sin(time * 27.7 + 1.3) +
0.07 * Math.sin(time * 43.1 + 0.8) +
(Math.random() - 0.5) * 0.09;
flicker = THREE.MathUtils.clamp(flicker, 0.38, 1.28);
fireLight.intensity = baseLightIntensity * flicker;
fireLight.position.x = Math.sin(time * 11.2) * 0.045;
fireLight.position.z = Math.cos(time * 9.7) * 0.045;
fireLight.position.y = 0.96 + 0.09 * Math.sin(time * 18.3 + 1.7);
fireLight.color.setHSL(0.065 + 0.025 * flicker, 0.9, 0.55);
for (const flame of flames) {
updateFlame(flame, time);
const opacity = flame.userData.baseOpacity * (0.62 + 0.55 * flicker);
flame.material.opacity = THREE.MathUtils.clamp(opacity, 0, 0.92);
}
ember.scale.setScalar(0.92 + 0.18 * flicker);
ember.material.opacity = 0.28 + 0.42 * flicker;
glow.scale.set(
0.92 + 0.12 * flicker,
0.82 + 0.18 * flicker,
0.92 + 0.12 * flicker
);
glow.material.opacity = 0.045 + 0.06 * flicker;
glow.position.copy(fireLight.position);
sparks.update(dt, time, flicker);
// Very subtle moon glow breathing
moonHalo.material.opacity = 0.09 + 0.025 * Math.sin(time * 0.45);
controls.update(dt);
renderer.render(scene, camera);
}
animate();
</script>
</body>
</html>Reasoning trace (24,906 tokens)
We need to build a self-contained HTML file using Three.js via import map. Requirements: low-poly 3D scene, campfire in forest clearing at night. Ground, rocks, logs, 30+ trees randomly placed, flames animated, warm point light flickering and casting shadows, sparks particle system, night sky with stars and moon, fog, OrbitControls with limits, resize, self-playing demo mode with start message? Games require self-playing demo; this isn't a game but has interaction via OrbitControls. We should still make it visually alive on first paint; maybe auto-rotate camera slowly until user interacts. The instruction: "Games must start in a self-playing demo mode..." This is a scene, not a game, but we can implement gentle automatic camera orbit that pauses on user interaction and resumes after idle. Also display a subtle "Drag to look around" hint that fades. Need ensure screenshot visible at 3s.
Let's craft robust Three.js scene.
Key components:
- Scene with fog: THREE.FogExp2 or linear Fog with dark blue color. Since night, fog color 0x0b1020, near/far or density.
- Renderer with shadowMap enabled, antialias, tone mapping maybe ACESFilmic, outputColorSpace sRGB.
- Camera perspective. OrbitControls with minPolarAngle maybe 0 to Math.PI/2 - 0.05? to keep above ground. maxDistance 60, minDistance 5. enableDamping. target at fire. autoRotate initially true speed 0.3; disable on user interaction; resume after idle via setTimeout.
- Ground: circle geometry or large plane? Low-poly: use PlaneGeometry with segments and randomly displace vertices slightly, compute flat normals. Need a clearing maybe circular flat area. Use geometry with radial segments? For low-poly aesthetic, use non-indexed and flatShading material. Colors maybe dark green/brown with vertex colors? Could use MeshStandardMaterial color 0x2a3b2a, roughness 1. Use plane with noise displacement. To avoid fire intersection, flatten central radius.
- Rocks: DodecahedronGeometry or IcosahedronGeometry with low detail and random scale, gray colors, scattered around fire.
- Logs: CylinderGeometry low radialSegments=5, rotate lying, placed around fire like campsite logs. Maybe 3-5 logs. Brown colors.
- Trees: at least 30. Low-poly conifer: cone leaves stacked, cylinder trunk. Use InstancedMesh for performance? Simpler: create groups and clones. Random positions around clearing, ring radius 12-55, avoid center. Use flatShading, colors variations dark green. Need shadows maybe cast/receive? For trees, castShadow true (but many shadows costly). Point light shadow only small. Directional moonlight with shadow maybe. Trees far from fire may not cast from point due shadow camera. Use one directional light from moon with soft shadow map, castShadow true for trees, ground receive. Point light shadows: castShadow true. Shadow map sizes moderate 1024. Need performance. We can limit point shadow radius. Trees 30-50; instancing could be better. Let's implement tree geometry as merged? Since low-poly and many, InstancedMesh for trunks and cones. But random sizes and positions with instanced matrices. InstancedMesh can cast shadows. Use multiple cone layers instanced? Each tree could be built as group but 60 objects ok. Simpler but good. Maybe create tree prototype group, clone into array. Cloning groups 50 with meshes = 200 meshes, still ok. Use cast shadows only trunk/leaves? Leaves may cast; point shadow maybe not too bad. We can disable tree cast shadows for point? They will cast from moonlight. Need moon shadows? Maybe directional moonlight low intensity no shadows to save, but requirement point light casts shadows. We'll make point light cast shadows for fire on ground/logs/rocks/trees near. Good.
- Moon: emissive sphere in sky. Need not interact with fog? Place far away with material fog=false maybe. Also directional light from moon.
- Stars: Points with buffer geometry random positions on sphere; PointsMaterial size 0.2-1, transparent, fog=false, maybe additive. Need avoid being blocked by fog. Use sky sphere? Maybe just black background color 0x050812. Stars points at radius 250-400. Moon sphere at radius 280.
- Fire flames: animated. Low-poly multiple cone/tetrahedron planes? Common: stack of 3-5 cones with MeshBasicMaterial additive blending orange, random height, pulse. Use geometry ConeGeometry(radius, height, 4, 1, open?) flat? Need animated: scale/position/rotation and vertices? Simpler: flame sprites? But low-poly 3D. Use multiple cones with different colors (yellow/orange/red) additive transparency, depthWrite false. Animate by scaling Y, jitter X/Z, rotate around Y, opacity. Or custom shader. Basic with vertex jitter? Could update geometry vertices per frame for a base flame mesh (non-indexed flatShading) using simplex noise? Need compact. Maybe use THREE.Points? Flames could be small glowing tetrahedrons? Let's craft a group:
- Fire wood: logs crisscross.
- Embers: small glowing rocks?
- Flames: 3 stacked cone meshes: outer orange transparent, middle yellow, inner white. Use ConeGeometry(0.35,1.1,5,1,true?) Actually open-ended cone? If open, no base; nice. material doubleSide? Additive blending. Animate scale: 1+sin(t*speed), x jitter.
- Additional flame tongues: 4 smaller cones around center, random phase, rotate and scale.
- Point light at fire, color 0xffaa55, intensity 8, distance 35, decay 2, shadows. Flicker intensity using noise combination of sin and random. Maybe position y=1.2.
- Sparks: particle system Points with BufferGeometry. positions, velocities, lifetimes, colors. Spawn from fire each frame; update position += velocity*dt, velocity.y decelerates? Add slight gravity? Need rising: positive y velocity and wind, fade out. Use PointsMaterial with size 0.08, transparent, vertexColors, blending Additive, depthWrite false. Update every frame; recycle when life <= 0. Keep count 150.
- Ground: plane 100x100 with segments 60x60? flatShading. Need low-poly. Use PlaneGeometry rotated -PI/2. Displace y = noise. Flatten center: if radius < 8, mix to 0. Color vertex colors maybe dark grass/brown? Could use MeshStandardMaterial with vertexColors. We can generate colors based on height and distance to fire (warmer near fire). But fire light will color. For ground material: MeshStandardMaterial({vertexColors:true, flatShading:true, roughness:1, metalness:0}). Need geometry.deleteAttribute('normal'); computeVertexNormals after noise. For flat shading, geometry.toNonIndexed? Actually MeshStandardMaterial flatShading:true uses face normals in shader, but vertex positions indexed okay. To get low-poly flat facets, flatShading true enough. Displacement may create normals. If using indexed plane, flatShading true yields faceted.
- Fog: scene.fog = new THREE.FogExp2(0x071020, 0.012?) But near fire visible. Or linear fog near 20 far 90. Need fade distant trees but stars not. Points material fog=false. Moon fog=false. Ground material fog true. For linear FogExp2 maybe too uniform. Use THREE.FogExp2(0x081224, 0.015). Distant trees at 50 will fade. But near fire at 8 visible. Good. Ensure background color same as fog for depth. Renderer clear color? scene.background = new THREE.Color(0x050812). Fog color maybe 0x070c18. Need stars visible. PointsMaterial fog=false.
- UI overlay: title, hint. Need no external fonts; use system monospace/sans. Maybe bottom left "Forest Campfire — low-poly". Hint "Drag to orbit, scroll to zoom; double-click? Auto orbit resumes after idle". Start message? Not a game, but we can show "Click and drag to explore". Need self-contained. Could add "Click or press a key to enable controls" to satisfy iframe keyboard? For OrbitControls mouse works if iframe clicked? We can have overlay that disappears on pointerdown, enabling controls. It should not block pointer events after. Use CSS pointer-events:none for overlay except start screen. Start screen full with semi-transparent and text. On click/keydown hide and controls enabled. But screenshot after 3s without interaction: if start screen covers? It can have message but scene behind visible? We need something meaningful visible. Better: scene auto-rotating; overlay hint at bottom, no full cover, controls always enabled. No start message needed unless game. The sandbox receives keyboard after click, but OrbitControls uses pointer. If user needs click anyway, okay. Requirement for games not relevant? But maybe they expect demo mode. We can show a non-blocking message: "Click or drag to look around — auto orbit active". It fades after 5s but visible at 3s. Use pointer-events none.
Need ensure no console errors. Use import map exactly. Use type module.
Potential issue: importmap must be before script. Include.
Let's design code details.
Tree geometry options:
For 30 trees, groups:
```
function makeTree(scale){
const group = new THREE.Group();
const trunkGeo = new THREE.CylinderGeometry(0.12,0.2,1.2,5,1);
const trunkMat = new THREE.MeshStandardMaterial({color:0x4a3526, flatShading:true, roughness:1});
trunkMesh castShadow, receiveShadow.
const layers=3;
const foliageGeo = new THREE.ConeGeometry(1,1.5,6,1); // flat shading
const foliageMat = new THREE.MeshStandardMaterial({color:0x24452e, flatShading:true, roughness:1});
for i...
```
Clone materials? For variations need separate materials or per mesh color. Could create shared geometries, but material color can be vertex? MeshStandardMaterial color not per instance unless InstancedMesh. Simpler clone material with random HSL. Many materials okay. To reduce, make a few materials and assign randomly. But low-poly aesthetic.
If using groups and random, 40 trees * (1 trunk + 3 cones) = 160 meshes. Good. Shadows: castShadow true, receiveShadow true. For point shadow, many meshes maybe okay. Shadow camera near/far. pointLight.shadow.mapSize=1024; bias=-0.0005. Trees far may be outside shadow camera? PointLight shadow uses perspective cube, shadow.camera.far default 500? Need set. Could set pointLight.shadow.camera.near=1, far=30. Only nearby shadows. Good.
Need place trees around clearing not on fire. Use polar random radius 10..55, angle random. Ensure min separation? Not necessary but could avoid overlaps using simple rejection. Use array accepted positions and min distance 4. For 50 trees. If rejection > attempts. Use radius 8? Let's choose count 48.
Ground center flatten: If x,z radius < 7: y=0; transition to 8-10: blend.
Campfire center: logs around. Fire radius small. Ground should maybe fire pit ring of stones. We'll make rocks arranged near fire and scattered.
Rocks: geometry DodecahedronGeometry(radius,0) or Icosahedron. Random scale, position. Some ring around fire radius 1.8, some scattered. material gray with color variations. cast/receive.
Logs for seating: 4 logs around at radius 3.2, oriented tangentially. Cylinder radius 0.25, length 2.4, radial 6. Rotate lying: z? To tangential: position angle, quaternion around Y angle + PI/2? Cylinder default Y axis; rotate to X axis by z? If we want log along tangent: group position; mesh.rotation.z = Math.PI/2 (makes along X), then group.rotation.y = angle + Math.PI/2? Let's implement: mesh.rotation.z = Math.PI/2; mesh.rotation.y = angle? Actually after mesh.rotation.z, cylinder axis is X local. group rotation around Y sets orientation in world. Use group.rotation.y = angle + Math.PI/2? If mesh local axis X, after Y rotation, axis vector becomes (cos(y),0,-sin? Let's not overcomplicate; logs can be randomly oriented.
Firewood: three or four small logs crisscross at fire center. Could add glowing embers with emissive material.
Flame animation:
Create flameGroup. Use ConeGeometry(0.4, 1.2, 5, 1, true) open ended. MeshBasicMaterial({color:0xff7733, transparent:true, opacity:0.7, blending:THREE.AdditiveBlending, depthWrite:false, side:THREE.DoubleSide}). Inner cone color 0xffcc66, scale 0.7. Core 0xffffff. Additional 4 flames offset.
For better animation, update vertices? Maybe simple scaling is enough. But requirement animated flames. Let's do dynamic vertex jitter on base geometries to make more organic. Each flame cone has non-indexed? ConeGeometry indexed. We can convert toNonIndexed and store base positions, then update positions per frame with noise. For multiple meshes, can use shared geometry and update. Simpler: use a flame custom ShaderMaterial? Need not. Vertex jitter: For each mesh, store basePos attribute. Every frame for each flame, update position = base + noise. This may be many vertices but small (cone 5 segments maybe ~ 5*? low). Nice. Let's implement a simple pseudo noise: sin(t + x*2 + y) etc. Or use random offset? Smooth.
Flame geometry: ConeGeometry(0.45, 1.3, 5, 2, true).toNonIndexed(); compute normals maybe not needed. Store base positions. For outer, middle, core. Add 3 side tongues. Animate: for each vertex, dx = (Math.sin(y*4 + t*7 + phase)*0.08 + Math.sin(t*13 + x*8)*0.04) * (heightFactor); top vertices move more? Could use original base Y normalized. Since open cone, base y=-height/2? Need center. We can store original height factor based on y relative to geometry bottom. Let's create cones positioned with base at 0. Use ConeGeometry radiusBottom? default centered. Move up by height/2. For vertex y: local relative to base? We can set geometry translate(0,height/2,0) before animation. Then base y=0, top y=height. Then heightFactor = y/height. Add jitter scaled by heightFactor; base stays. Also slight inward/outward radius. Update normals? Basic material doesn't need normals? MeshBasicMaterial ignores lights, but transparent, no shading. Good.
For flames using additive, shadows not cast. Fire light below them. Good.
Ember glow: MeshBasicMaterial color 0xff4422 transparent, small cone/sphere? Maybe use a few small glowing particles or stones.
Sparks system:
```
const sparkCount=180;
const positions = new Float32Array(sparkCount*3);
const colors = new Float32Array(sparkCount*3);
const life = new Float32Array(sparkCount); const vel...
function respawn(i){ positions y=0.7+rand*0.2; vel = random small outward + y 1.5..2.5; life=rand 1..2.5; color yellow orange; }
```
All initially below? Use life <=0 to spawn. Update dt. Add gentle noise. Points size maybe 0.1. Material depthWrite=false, transparent, vertexColors, blending Additive.
Need ensure points not too large; PointsMaterial sizeAttenuation true.
Moon: sphere radius 8 at pos (80,60,-120). MeshBasicMaterial color 0xddeeff, fog=false. Maybe add glow sprite? Use basic sphere and maybe transparent larger glow. Stars: random positions on sphere radius 250-350. Points material size 0.8? Since far, sizeAttenuation false maybe use fixed pixel size 1.5. Use THREE.PointsMaterial({size:1.2, sizeAttenuation:false, color:0xffffff, transparent:true, opacity:0.9, fog:false}). Random size? Could create vertex colors for brightness. Geometry count 800.
Sky: maybe gradient background using scene.background = null and a large sphere? Simpler black. But fog color dark. Add subtle horizon glow from fire? We can add large cylinder? Not needed.
Camera auto-orbit: controls.autoRotate = true; controls.autoRotateSpeed = 0.6; on pointerdown set idle timer? Need controls listen: controls.addEventListener('start', () => autoRotate=false; clearTimeout); 'end' set timer 4s resume. If user presses key? Not necessary. Also if no interaction, scene alive.
Resize: window.addEventListener('resize', ...) update camera aspect, renderer size, maybe pixelRatio min(devicePixelRatio,2).
Need renderer.shadowMap.enabled = true; type PCFSoftShadowMap. Tone mapping: ACESFilmic, exposure 1.0/1.2. For night, point light intensity high. Physical lights? In Three r170, intensities in candela? For point light with decay=2, intensity 50 maybe too high. Need tune. Since tone mapping. Use renderer.toneMapping = THREE.ACESFilmicToneMapping; toneMappingExposure = 1.0. Directional moon intensity 0.15. Ambient/hemisphere 0.06. Point light intensity 25? For distance 25 decay 2, maybe too dark. We'll tune. Could set pointLight.intensity = 35. Shadow. For MeshStandardMaterial roughness 1. Use hemisphere light 0x223344/0x081008 intensity 0.2? Night should dark. Warm point light flicker 18..25. Use baseIntensity.
Since tone mapping can make bright flames okay. Add point light helper? no.
Ground color: vertexColors can be set. Use geometry colors attribute. For each vertex, distance to center. near fire brownish, farther greenish/dark. Also based on y. Need create Float32Array colors. But if plane indexed with vertices. Use color attribute; flatShading with vertexColors will still use interpolated? Actually flatShading uses face normals, color interpolation still from vertices. Could be okay. For low-poly distinct facets, non-indexed with constant face color better. But can use vertexColors. To get flat color per triangle, convert to non-indexed and assign per-vertex colors same for each triangle. Simpler: after geometry toNonIndexed, for each triangle set color based on first vertex? But positions. Let's do non-indexed: PlaneGeometry -> rotate? Use non-indexed then displace? Non-indexed vertices have many duplicates, displacement must be consistent by position. Hard. Could keep indexed, okay. Or use MeshStandardMaterial color only and flatShading. Use vertexColors maybe not necessary. But a colored ground near fire could enhance. Keep simple color ground 0x1c2a1c. Low-poly via flatShading. Maybe vertex colors for subtle. Let's implement indexed vertex colors: set color attribute after displacement based on position; flatShading true.
Plane: width=120, height=120, segments 60,60. That's 3721 vertices, fine. Rotate x -PI/2 after geometry? Easier: geometry = new THREE.PlaneGeometry(120,120,70,70); for each vertex before rotation: x = pos.getX(i), y = pos.getY(i) (in plane). z = noise. If rotated, need apply rotation to geometry: geometry.rotateX(-Math.PI/2); then positions have y as plane coords. We can displace after rotate using x,z. Or displace before as y then rotate makes z? Let's do after rotate: iterate positions: x=pos.getX(i), y=pos.getY(i), z=pos.getZ(i); new y = noise(x,z). Set. Good.
Noise function: simple combination:
```
function noise(x,z){ return Math.sin(x*0.2)*Math.cos(z*0.22)*0.8 + Math.sin(x*0.07+z*0.11)*1.2 + Math.cos(x*0.13-z*0.09)*0.7; }
```
For low-poly amplitude ~0.6. Flatten clearing radius 8: blend from 0 at <6 to full at >10.
Use `float clamp`.
Also maybe slight central depression? no.
Need receive shadows.
Tree placement should account ground height: set y = heightAt(x,z) - 0.05. We can write heightAt same function with flattening. For fire pit, ground flat.
Rocks near fire should sit on y.
Fire position: y=0 maybe ground flat.
Flame height and light:
Point light at (0,0.9,0), intensity flicker. Flames from y=0.25 upward. Logs around at y.
Firewood logs: cylinder small. Maybe use logGeometry radius 0.12 length 1.2. Rotate in pile. Add glowing embers? Use a small MeshStandardMaterial emissive 0xff3300 at center? But point light already. Could add fire pit: dark circle? Maybe not.
Need logs seating: use brown cylinders. Ensure they don't block too much. Place at radius 2.8, y=0.22. Rotate. Cast shadows.
Rocks: ring around fire radius 1.7? But logs pile radius maybe 0.7. Stones ring radius 1.3 to 1.7. Use random.
Tree count: at least 30; do 56. Need random placement not on fire and clearing maybe radius >= 7? Actually logs at radius 2.8, trees beyond 8. Good. Avoid tree on rocks? not needed.
Potential performance: 56 trees * shadows from point: point shadow map includes all; 168 meshes plus ground 1, rocks 30, logs 8 = ~200. Shadow map 1024, okay. But point light shadow far 30, only near. We can set castShadow true only for meshes within 30? Hard. Simpler all cast. Could reduce to 36 trees. Requirement at least 30. Use 48.
Materials: For shadows, MeshStandardMaterial. Use color variations.
Need import OrbitControls: `import { OrbitControls } from 'three/addons/controls/OrbitControls.js';`
Need self-contained CSS: body margin 0, canvas display block, overlay absolute. Prevent scrollbars. Use `overflow:hidden`. Add `position:fixed` for UI. For 375px width, UI wraps. Use font-size responsive.
Start message: "Click or press a key to control camera" perhaps. But if controls active no need. We can show hint and it fades after 8s. It won't block pointer. If user clicks canvas, controls work.
Potential iframe: keyboard only after click; hint says Click or drag. Good.
Need zero errors: Ensure variables defined before use. Avoid deprecated features. THREE.Color.setHSL okay. geometry.rotateX. `mesh.geometry.attributes.position.setUsage(THREE.DynamicDrawUsage)`. For flame geometry, toNonIndexed; delete normal? MeshBasicMaterial doesn't need normals but may still request if geometry has normal? Not issue. `geometry.setAttribute('position', ...)`? Actually geometry.toNonIndexed() returns new geometry. We can store base positions in a Float32Array clone. Each frame copy jittered into position attribute.
Flame update:
```
const flameCones=[];
function createFlame(radius,height,color,opacity,phase){
const geo = new THREE.ConeGeometry(radius,height,4,1,true);
geo.translate(0,height/2,0);
geo = geo.toNonIndexed(); // But cone open has no top? toNonIndexed ok.
geo.deleteAttribute('normal');
const base = geo.attributes.position.array.slice();
const mat = new THREE.MeshBasicMaterial({color, transparent:true, opacity, blending:THREE.AdditiveBlending, depthWrite:false, side:THREE.DoubleSide});
const mesh = new THREE.Mesh(geo,mat);
mesh.userData = {base, height, phase, speed};
return mesh;
}
```
For ConeGeometry(radius, height, radialSegments=5, heightSegments=1, openEnded=true). `openEnded` true no base. If toNonIndexed, position count? Let's approximate.
Animate:
```
function updateFlame(mesh,t){
const pos = mesh.geometry.attributes.position;
const base = mesh.userData.base;
const h = mesh.userData.height;
for (let i=0;i<pos.count;i++){
const ix=i*3;
let x=base[ix], y=base[ix+1], z=base[ix+2];
const hf = Math.max(0, Math.min(1, y/h));
const s = mesh.userData.phase;
const amp = hf*mesh.userData.amp;
x += Math.sin(t*8 + s + y*6)*amp;
z += Math.cos(t*7 + s*2 + y*5)*amp;
// shrink radius at top? already cone; maybe y *= 1 + Math.sin(...)*0.05;
y += Math.sin(t*10 + s + x*3)*0.02*hf;
pos.array[ix]=x; pos.array[ix+1]=y; pos.array[ix+2]=z;
}
pos.needsUpdate=true;
}
```
Need base y/h: after translate base at 0 top h. Good. If heightSegments=2 more vertices. Use 3. For side flames smaller.
Opacity flicker: mesh.material.opacity = baseOpacity*(0.7+0.3*noise). MeshBasicMaterial supports transparent.
Add flameGroup to scene at (0,0,0).
Light flicker:
```
const time = clock.getElapsedTime();
const flicker = 0.8 + 0.2*Math.sin(t*15) + 0.05*Math.sin(t*31)+0.07*Math.sin(t*47) + (Math.random()-0.5)*0.06;
fireLight.intensity = baseIntensity*flicker;
```
Maybe avoid random per frame jitter too much. Use smooth noise plus random. Good.
Sparks update: Use clock delta capped.
```
const dt = Math.min(clock.getDelta(),0.05); const t=clock.elapsedTime;
```
If clock.getDelta called once per frame. Use elapsed = clock.elapsedTime.
Spawns per frame: `sparkAccum += dt*40; while(sparkAccum>1) spawn;` Or all particles recycle. Use life array; when life<=0, spawn. To keep continuous, spawn from inactive count limited.
Initial all inactive? Set life=0 and positions at hidden (0,-100). Then update will spawn all first frame? If all inactive, spawning 180 may be too many burst. Better stagger: life = Math.random()*2.5, and when respawn set. For first visible, good.
Sparks update:
```
for i:
life[i]-=dt;
if(life[i]<=0) respawn(i);
else {
// velocity: vy maybe positive decays, add noise
const ix=i*3;
positions[ix]+=vel[ix]*dt; ...
vel[ix] += Math.sin(t*3+i)*0.1*dt;
vel[iy] -= 1.2*dt; // gravity slows rise
vel[iz] += ...
// opacity? PointsMaterial cannot per-point opacity. Could use color dimming over life. Since additive, reduce color to black. Store base colors. Set colors[i] = base * (life/maxLife) maybe.
}
```
Need baseColors Float32Array. On respawn assign random orange/yellow, store. Each frame set color attribute = baseColor * (life/maxLife)^2. PointsMaterial vertexColors. Good.
Potential issue: Points size with many overlapping, fine.
OrbitControls limits:
```
controls.minPolarAngle = 0; // top ok
controls.maxPolarAngle = Math.PI/2 - 0.05; // stay above ground, not below.
controls.minDistance=6; controls.maxDistance=60;
controls.enablePan=false? To prevent camera below ground? Pan could move target. Disable pan or limit target y. Set enablePan false. controls.target.set(0,0.8,0); controls.update();
```
If autoRotate, need controls.update(dt) in loop. Use `controls.update();` after changes.
Need if user clicks, no pointer events? renderer canvas in body. Overlay pointer-events:none. Good.
Fog: `scene.fog = new THREE.FogExp2(0x081018, 0.018);` Stars fog false, moon fog false. Trees far fade. Ground far fades. If density 0.018, at 40 exp(-1.44)=0.24 visible, 60 0.34 fog. Good.
Background color: `scene.background = new THREE.Color(0x04070d);` Fog color maybe slightly brighter 0x07101b.
Moonlight: Directional light from moon direction. Color 0x7e9bd9 intensity 0.12. No shadow to avoid point only? Could cast shadows from moon with lower quality? Maybe no. But requirement point light casts shadows. So point shadows enough. For ground and trees, point light shadows only near fire; moonlit area shadows less. Could use directional with shadow too, but overhead. Add directional shadow with 1024? Maybe scene still. For low-poly night, maybe no directional shadows. The point light casts shadows onto nearby ground/rocks/logs/trees around fire; that's visible. Distant trees only moon ambient. Good.
Ambient/hemisphere: `const hemi = new THREE.HemisphereLight(0x1a2f4a, 0x0b1208, 0.25);` Maybe scene dark but stars. Use `new THREE.AmbientLight(0x101820, 0.25)`. Need ensure trees visible. Moon directional adds. Good.
Maybe add subtle rim with hemisphere.
Ground shadows: receiveShadow true. pointLight.shadow.bias -0.001, radius? renderer.shadowMap.type PCFSoft; material? Point shadow radius maybe. Use `pointLight.shadow.mapSize.set(1024,1024); pointLight.shadow.camera.near=0.8; pointLight.shadow.camera.far=30; pointLight.shadow.bias = -0.001;` `pointLight.shadow.radius=2`? PCFSoft uses radius? Maybe not for point? okay.
Log seating: They might cast nice shadows.
Need "low-poly": flatShading true on all standard materials. Use few segments.
Let's outline code:
```
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Low-poly campfire</title>
<style> ... </style>
<script type="importmap">...</script>
</head>
<body>
<div id="ui">...</div>
<div id="hint">Click or drag to look around</div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// renderer, scene, camera
// helper functions
// build ground
// build trees
// build rocks, logs
// fire, sparks
// stars, moon
// animate
</script>
</body>
</html>
```
Potential bug: `scene.background = new THREE.Color();` with fog color slightly. Need renderer.setClearColor maybe not.
UI: Use `position:absolute; left:18px; bottom:18px;` and maybe top-right. Font.
Need no horizontal scrollbar. body overflow hidden.
Random placement:
```
const treePositions=[];
function addTree(){...}
function tryPlace(minRadius,maxRadius,minDist, attempts){ for ... angle=rand*2PI; r = Math.sqrt(rand)*(maxR-minR)+minR? For uniform use sqrt. x = Math.cos(angle)*r; z = ...; if ground height? no. Check distance. return null. }
```
Clearing radius maybe min 8. For trees: minR 9 maxR 52. Need ensure fog fade. Could add more trees farther. Count 45. If some reject, continue until accepted count.
Tree building with shared geo:
```
const trunkGeo = new THREE.CylinderGeometry(0.12, 0.18, 1, 6, 1);
trunkGeo.translate(0,0.5,0); // base at 0.
const coneGeo = new THREE.ConeGeometry(1,1,6,1); // base at 0? translate
for each tree: scale=0.9+rand*1.4; trunk scale; foliage layers at y=...; material color HSL.
```
Need cone geometry translate by height/2 so base at 0. ConeGeometry centered. `const foliageGeo = new THREE.ConeGeometry(1,1,6,1); foliageGeo.translate(0,0.5,0);`
For tree, use layers:
- bottom radius 1.3, height 1.6, y 0.9
- middle radius 1.0, height 1.4, y 1.8
- top radius 0.65, height 1.3, y 2.7
Scale by s. Trunk height maybe s*1.2.
Maybe use 4 layers for variety? 3. Use geometry per mesh? Shared geometry can be scaled by mesh.scale. Good.
Materials: create arrays for trunk/foliage? MeshStandardMaterial clone with random color. If each mesh has own material, memory okay. But can share trunkMat, foliageMats array. Since variation color, clone from base. But clones still many. Good.
```
const trunkMatBase = new THREE.MeshStandardMaterial({color:0x4b3423, flatShading:true, roughness:1});
const foliageMatBase = new THREE.MeshStandardMaterial({color:0x22482f, flatShading:true, roughness:1});
...
const trunkMat=trunkMatBase.clone(); trunkMat.color.offsetHSL(0, rand*0.1, (rand-0.5)*0.08);
```
`offsetHSL` exists.
Rocks:
```
const rockGeo = new THREE.DodecahedronGeometry(0.45,0);
// maybe irregular: scale nonuniform, random rotation.
for i 18: radius from 1.5 to 2.2 ring; else 4 to 25.
scale = 0.5 + rand*1.2.
```
Color 0x5c5c68 etc.
Logs:
```
const logGeo = new THREE.CylinderGeometry(0.22,0.26,2.3,6,1);
logGeo.rotateZ(Math.PI/2); logGeo.translate(0,0.22,0); // lying with y center?
```
If rotateZ, cylinder axis X. Need not translate y? If original centered y; after rotate axis x and centered y=0? We want bottom on ground. Use mesh.position.y = 0.25. Or set geometry translate after rotate? Let's do geometry rotateZ(PI/2), then position.y = radius. But after rotate, y still centered at 0 (radius vertical). Good.
For log seating, create groups at angle, position y ground+0.25, rotation.y=angle + maybe random, add slight tilt? Use mesh rotation.z? Since geometry rotated, group rotation Y aligns. Add small y bob? no.
Firewood logs: 4 smaller logs crisscross around center. Use geometry radius 0.1 length 1.1. Create pile:
```
for angle in 0, PI/2... rotate y=angle, position z=0.25? Use cross? Simpler add 3 logs at y=0.12 with rotations.
```
Need not overthink.
Fire base: dark circle? Add small black mesh? Maybe stones ring enough.
Embers: a few tiny glowing dots at base. We can add small spheres with MeshBasicMaterial additive orange around (0,0.2,0). Or use Points. But flames cover.
Sparks material: `new THREE.PointsMaterial({ size: 0.13, vertexColors: true, transparent: true, opacity: 1, blending: THREE.AdditiveBlending, depthWrite: false, sizeAttenuation: true, fog: false })`. Need `fog:false` so sparks not faded maybe they fade manually.
Stars: Points with no sizeAttenuation. `const starGeo = new THREE.BufferGeometry();` positions, colors. PointsMaterial({size:1.3, sizeAttenuation:false, vertexColors:true, transparent:true, opacity:0.9, fog:false}). Need random positions above horizon? For whole sphere behind trees? Put positions where y > -0.1. Generate random direction, if y < -0.1, flip? Better: `phi = Math.acos(1 - Math.random()*(1+0.05))`? Need hemisphere. Use random unit vector: x=rand*2-1 etc, normalize, if y<0 then y=-y. Radius 300. Good. Some star brightness.
Moon: Sphere radius 7 at direction normalized. Need place above horizon. `moonPos = new THREE.Vector3(120,90,-160).normalize().multiplyScalar(280)`. Directional light from same direction. Add glow: `new THREE.Mesh(new THREE.SphereGeometry(10,16,12), new THREE.MeshBasicMaterial({color:0x556688, transparent:true, opacity:0.15, fog:false, blending:THREE.AdditiveBlending}))`. Moon material emissive? MeshBasic. Maybe use `MeshStandardMaterial({color:0xd7e3f4, emissive:0x99aabb, emissiveIntensity:1, fog:false})`. With no lights? Basic easier. Add slight texture? no.
Need stars/moon not fog. MeshBasicMaterial fog property? `new THREE.MeshBasicMaterial({..., fog:false})` yes.
Camera: initial position x=8,y=6,z=8? But autoRotate around target. Use camera.position.set(9,6,7). Good. Near 0.1 far 1000.
Animation loop:
```
let elapsed=0;
function animate(){
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(),0.033);
elapsed += dt;
updateFlames(elapsed);
fireLight.intensity = ...;
fireLight.position.x = Math.sin(elapsed*13)*0.05; etc
sparks.update(dt, elapsed);
controls.autoRotate = autoRotateActive? controls.autoRotate : controls.autoRotate? We'll manage.
controls.update();
renderer.render(scene,camera);
}
```
`clock.getDelta()` also updates `clock.elapsedTime`; if we need t, use `clock.getElapsedTime()`? But calling getDelta after getElapsedTime? Let's use:
```
const clock = new THREE.Clock();
let time=0;
function animate(){ const dt=Math.min(clock.getDelta(),0.033); time += dt; ... }
```
Flicker formula:
```
const flicker = 0.75 + 0.18*Math.sin(time*13) + 0.1*Math.sin(time*37+1.2) + 0.05*Math.sin(time*71+0.8);
fireLight.intensity = baseIntensity * Math.max(0.25, flicker + (Math.random()-0.5)*0.12);
```
Flame material opacity tied to flicker.
Flame update also uses time.
Sparks class:
```
class Sparks { constructor(count){...} respawn(i){ ... } update(dt,time){ ... }}
```
Need `points.geometry.attributes.color.needsUpdate=true;`
Potential issue: `points.geometry.setAttribute('color', new THREE.BufferAttribute(colors,3));` material.vertexColors true. Good.
For spark max life store `maxLife[i]` for ratio.
Ground color? If not vertex colors, MeshStandardMaterial color with flatShading. Could be dark and shadows. Let's maybe vertex colors for low-poly. But need ensure attribute created. PlaneGeometry indexed has position 3721; color array same. For each vertex i:
```
const x=pos.getX(i), y=pos.getY(i), z=pos.getZ(i);
const r=Math.hypot(x,z);
let amp = 1 - Math.min(1, Math.max(0, (r-6)/6)); // full at far? if r<6 amp=0, r>12 amp=1
y = noise(x,z)*amp*0.8;
pos.setY(i,y);
// color: mix grass brown near fire
const warm = Math.max(0, 1 - r/8);
const base = new THREE.Color();
base.setHSL(0.28 + Math.random()*0.04? per vertex not stable? Need deterministic based on x,z) ...
```
Use deterministic. But vertex colors will interpolate across faces. Fine.
Maybe avoid random per vertex causing noise. Use:
```
const c = new THREE.Color().setHSL(0.27, 0.3, 0.09 + Math.sin(x*0.4)*0.01 + Math.cos(z*0.3)*0.01);
if (r<6) c.lerp(new THREE.Color(0x3a2b1f), warm*0.4);
colors[i*3]=c.r...
```
Need performance. We'll define. But if ground material has vertexColors true, color attribute. Then set `geometry.computeVertexNormals();` after displacing. Good.
Potential problem: PlaneGeometry after rotateX(-PI/2) positions y=0, z coordinates? Let's do geometry = PlaneGeometry(120,120,72,72); geometry.rotateX(-Math.PI/2); Then pos array x,z in plane, y up. Great.
HeightAt: Use same noise and flatten. Need for tree placement. Implement:
```
function noiseHeight(x,z){ return Math.sin(x*0.12)*Math.cos(z*0.15)*0.7 + Math.sin(x*0.07+z*0.11)*0.5 + Math.cos(x*0.19-z*0.08)*0.35; }
function groundY(x,z){ const r=Math.hypot(x,z); const blend=clamp((r-6)/7,0,1); return noiseHeight(x,z)*blend*0.9; }
```
For ground geometry, set y=groundY. Good.
Trees: `tree.position.set(x, groundY(x,z)-0.08, z)`; scale.
Rocks: similar, maybe sink.
Logs: seating at radius 3.0, groundY=0 due flatten? r=3<6 blend=0 -> 0. position y=0.25.
Firewood at center.
Need clearing: trees start minR 9 so not too close.
Auto orbit and user control:
```
controls.autoRotate = true;
let userIdleTimer;
controls.addEventListener('start', () => { controls.autoRotate=false; clearTimeout(userIdleTimer); });
controls.addEventListener('end', () => { userIdleTimer=setTimeout(()=>controls.autoRotate=true, 3000); });
```
If user clicks on hint? Hint pointer-events none. Good.
Add UI:
- Top-left small card with title and maybe stats? Could be nice. Use `backdrop-filter: blur(10px)`? CSS okay. But in sandbox maybe. Keep simple.
- Bottom-right "Three.js".
- Center hint with pointer-events none, fade after? Use CSS animation `opacity` after? But it should be visible at screenshot 3s. Maybe fade at 6s. Use `animation: fadeHint 1s ease-in-out 6s forwards`.
Need "Click or press a key to start" maybe not for demo. We can say "Click or drag to orbit". There is no game. But to satisfy any expectation, include "Auto-orbit demo — click, drag, or press a key to control". It doesn't block.
Potential hidden UI issue: if controls disabled until click? No, enabled from start. But if overlay pointer events none, user can click canvas. Good.
Let's write code with careful syntax.
Detailed flame geometry update:
ConeGeometry with openEnded true: For ConeGeometry(radius, height, radialSegments, heightSegments, openEnded), openEnded default false. If openEnded=true, no base. It still has side faces from base circle to apex. For heightSegments >1, multiple rings. Vertex positions: base circle y=-height/2, top ring? apex duplicated for radial segments. `translate(0, height/2, 0)` base at y=0, apex at height. Good.
`geo.toNonIndexed()` returns positions duplicated. Store base as Float32Array slice. For update, loop `pos.count`.
But base y/h can be exactly 0 at base, maybe apex at h. For open cone, radial segments at base, maybe one at top ring? HeightSegments=2 yields y=0, h/2, h. Good.
Add jitter:
```
for i:
const x0=base[i*3], y0=base[i*3+1], z0=base[i*3+2];
const hf = y0 / h; // base 0 apex 1
const amp = mesh.userData.sway * (0.15 + hf);
const px = x0 + Math.sin(time*8.7 + phase + y0*4.3)*amp + Math.sin(time*19.1 + phase*2 + z0*6)*amp*0.3;
const pz = z0 + Math.cos(time*9.1 + phase*1.7 + y0*3.7)*amp + Math.cos(time*21.3 + phase + x0*5)*amp*0.3;
const py = y0 + Math.sin(time*11.3 + phase + x0*7 + z0*5)*mesh.userData.lift*hf;
```
`amp` small like 0.06-0.12. lift 0.04. Good.
Scale entire mesh? Maybe no.
But if we modify positions, cone may distort weird. That's fine. Need not compute normals.
Flames group:
```
const flames=[];
function addFlame(radius,height,color,opacity,sway,phase){...}
addFlame(0.48,1.6,0xff5f28,0.34,0.11,0);
addFlame(0.38,1.3,0xff9238,0.45,0.09,1.1);
addFlame(0.28,1.0,0xffd07a,0.65,0.07,2.2);
addFlame(0.18,0.75,0xfff3c4,0.85,0.05,3.1);
// side tongues offset
for i 0..3: addFlame(0.22,0.85,0xff8030,0.36,0.09,i*2, offset x/z)
```
For side tongues, set mesh.position.x/z. Need store base? base positions local. Good.
Flames update also rotate group slowly? We can set each mesh.rotation.y += dt*speed? But vertices updated local, rotation okay. Maybe outer flame rotate slowly: `mesh.rotation.y = Math.sin(time+phase)*0.2`. For additive geometry okay.
Core flames use MeshBasicMaterial color with opacity. Good.
Need ensure additive flames not hidden by logs? Set renderOrder? `mesh.renderOrder=10`. But transparent depthWrite false; may render after opaque. Good.
Sparks renderOrder 20.
Potential issue: MeshBasicMaterial with transparent and additive can appear too bright. Adjust opacity.
Fire pit rocks: Ring maybe cast shadows. Good.
Now, code details for tree: Use group. If scale random, children scale individually. Simpler:
```
function makeTree(){
const g = new THREE.Group();
const scale = 0.8+Math.random()*1.4;
const trunk = new THREE.Mesh(trunkGeo, trunkMat.clone()); trunk.scale.set(scale, scale*0.8+Math.random()*0.6, scale); ...
g.add(trunk);
for layer... coneMesh = new THREE.Mesh(foliageGeo, foliageMat.clone()); coneMesh.scale.set(...); coneMesh.position.y=...; coneMesh.rotation.y=Math.random()*Math.PI; g.add(coneMesh);
g.scale.setScalar(scale); // maybe instead scale whole group.
```
If group scale all, trunk height etc. Use group.scale = scale. Then positions in unscaled. Simpler. For height variation, group.scale.y separate.
```
g.scale.set(s, s * (0.8+Math.random()*0.6), s);
```
Then ground placement based on group height ~3*? If y scale. But base at 0; okay.
Shared geometries need normals; MeshStandardMaterial with flatShading. `computeVertexNormals` not needed for primitives? CylinderGeometry has normals. flatShading uses face normals via shader? Actually standard material flatShading uses derivatives in fragment? okay. No need.
But if we clone material for each tree, 48*(1+3)=192 materials. Might be okay but could be heavier. Instead create 8 foliage colors, 4 trunk colors. `const trunkMats = ...` and `const foliageMats = ...`. Then choose random. Good. Materials shared, shadows still fine.
```
const trunkMats = [ ... 4 colors ... ];
const foliageMats = [ ... 6 greens ... ];
```
For logs and rocks materials: create few.
Potential issue: `THREE.Color.offsetHSL` returns this. Need clone. Use:
```
for (let i=0;i<4;i++){ const m=trunkBase.clone(); m.color.offsetHSL(0,0,(i-1.5)*0.05); trunkMats.push(m); }
```
Need low-poly cones for trees: radial segments 5 or 6. `ConeGeometry(1,1,5,1)`. Good.
Could use `MeshLambertMaterial` instead standard for perf? Standard with roughness for shadows. Low-poly night maybe standard.
Shadows: For point light, only cast from fire side. Set `fireLight.shadow.mapSize.width=2048?` maybe too high. 1024. `fireLight.shadow.camera.far=25` so trees near fire cast shadows. `fireLight.shadow.bias=-0.0015; fireLight.shadow.normalBias=0.02`? In r170 PointLight shadow normalBias exists? Yes LightShadow has normalBias? Could set if no error? I think `normalBias` added. Use `pointLight.shadow.bias = -0.0005; pointLight.shadow.normalBias = 0.02;` safe? Should be. If not? It exists.
Potential issue: `controls.autoRotate` with damping, update requires `controls.update(delta)`? OrbitControls.update accepts delta for autoRotate? In r170 yes `update(deltaTime)`? We can call `controls.update()` fine; if no delta, autoRotate uses internal clock? Maybe not? In examples they call update with deltaTime. Let's call `controls.update(dt);` It supports optional delta. Good.
Need if `renderer.setPixelRatio(Math.min(devicePixelRatio,2))`. On resize same.
UI code:
```
<div class="panel" id="title">
<h1>Low-Poly Campfire</h1>
<p>A nighttime forest clearing</p>
</div>
<div id="hint">Click, drag, or press a key to orbit — scroll to zoom</div>
```
Pressing key doesn't do anything if keyboard only controls not bound. Maybe say click/drag. Could bind key to resume autoRotate? Or toggle autoRotate? To satisfy "press a key", add keydown: if not game? We can hide hint. No actions required. Add `window.addEventListener('keydown', () => hideHint())`.
If iframe requires click for keyboard, keydown only after click; okay.
CSS:
```
*{box-sizing:border-box} html,body{height:100%;margin:0;overflow:hidden;background:#02040a}
canvas{display:block}
.panel{position:absolute; top:18px; left:18px; color:#dce9ff; font-family: ui-sans-serif,...; background:rgba(7,14,28,0.55); padding:10px 14px; border:1px solid rgba(120,160,255,0.2); border-radius:14px; backdrop-filter:blur(8px); max-width:320px; pointer-events:none}
h1{margin:0 0 4px; font-size:16px; letter-spacing:0.02em; font-weight:600}
p{margin:0;font-size:12px; opacity:.75}
#hint{position:absolute; bottom:18px; left:50%; transform:translateX(-50%); color:#e6f0ff; background:rgba(10,15,25,.55); padding:8px 14px; border-radius:999px; font:12px ...; pointer-events:none; animation: fade .7s ease 8s forwards; white-space:nowrap}
@media (max-width:620px){ #hint{white-space:normal; width:min(92vw,360px); text-align:center} .panel{right:18px; max-width:none} }
@keyframes fade{ to{opacity:0} }
```
Need if no `backdrop-filter`? works.
Let's check possible errors:
- Import map in head before module. Good.
- Use `three/addons/controls/OrbitControls.js` from import map. Good.
- Use `THREE.SRGBColorSpace`? In r170 renderer.outputColorSpace = THREE.SRGBColorSpace default. We can set `renderer.outputColorSpace = THREE.SRGBColorSpace;` safe.
- `THREE.ACESFilmicToneMapping` safe.
- `THREE.AdditiveBlending` safe.
- `geometry.toNonIndexed()` safe. Need if openEnded geometry has groups? no.
- `pos.array` Float32Array. Good.
- `starGeo.setAttribute('position', new THREE.BufferAttribute(stars,3));` and 'color'. Good.
- `points.frustumCulled=false` for sparks/stars? For sparks moving, frustum culling based initial bounding sphere could cull. Set `sparkPoints.frustumCulled = false;` and stars maybe false? Moon? For sparks positions change, set false. Stars static fine but bounding sphere computed? We can call geometry.computeBoundingSphere after setting positions or frustumCulled=false. Do `stars.geometry.computeBoundingSphere(); spark.geometry.computeBoundingSphere(); spark.frustumCulled=false;` For flame positions updated, mesh bounding sphere not updated; frustum culling might cull if base bounding sphere outdated? Flames near center, small, maybe okay but set `flameMesh.frustumCulled=false;` for all moving geometry? Not necessary but safe for sparks. Flames vertices slightly moved, bounding sphere remains, no issue. If frustumCulled based on initial, still inside. But set false to be safe.
- For ground color attribute, set before material? Need `geometry.setAttribute('color', ...)`; material `vertexColors:true`. Good.
- `geometry.computeVertexNormals()` after position and before render. Good.
Need tune intensities:
- Point light base 18? In Three r170 physical lights: `THREE.ACESFilmicToneMapping`. MeshStandardMaterial with pointLight.decay=2, intensity candela. At distance 5, illuminance ~intensity/(4πr²)? Actually PointLight intensity in candela; light contribution roughly intensity/distance^2. For radius 5, intensity 100 gives 4? For firelight radius 10, intensity 150. Need tune. Many examples with point light intensity 100. Let's choose baseIntensity = 70, distance 30, decay 2. At 3m: 70/9=7.8; tone maps. At 8m: 1.1. Good. Flicker. Might be enough. Ambient 0.18, hemisphere 0.25. Directional moon 0.25. Firelight maybe 50. We'll set 80 and see? Can't test. Need not too bright. Low-poly scene can handle. Use `pointLight.intensity=80; pointLight.distance=28; pointLight.decay=2;` base flicker 0.7-1.1 => 56-88. Good. Ground color dark 0x141d14 so not blown. Maybe set toneMappingExposure=1.0. Good.
- If tone mapping ACES makes orange less saturated? okay. Could use `renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure=0.9;`. Fine.
Flames additive may appear bright; adjust opacities: outer 0.2, mid 0.35, inner 0.55, core 0.8.
Need ensure no black background with fog? Good.
Let's consider fire light casting shadows: PointLight shadow with many trees and rocks. But flames additive not cast. Logs near cast. Should look good. Need `renderer.shadowMap.enabled = true; renderer.shadowMap.type=THREE.PCFSoftShadowMap;` For point shadows maybe square artifacts; okay.
Maybe add `fireLight.shadow.focus=1;` Not needed.
Potential issue: `scene.fog` affects stars if `fog` not disabled. We set stars material `fog:false`, moon/glow basic `fog:false`, sparks maybe `fog:false`. Ground/trees affected. Good.
Maybe add subtle "moonlight ambient" via hemisphere: sky color 0x1d2d55, ground 0x120b07. Intensity 0.28. Directional from moon: 0x5c7ba7 intensity 0.22. Enough.
Need "renderer fills window": canvas appended body; `renderer.setSize(window.innerWidth, window.innerHeight);`
Potential issue: If using importmap and module, need ensure no `document.createElement('canvas')` appended to wrong. Use `document.body.appendChild(renderer.domElement);` CSS canvas display block.
Potential issue: `controls.maxPolarAngle = Math.PI * 0.48;` This keeps above ground but if target at y=1 and camera distance 5, can go below target but not below horizontal? At polar near π/2 camera y = target.y + distance*cos(polar), positive if target.y>0. At polar exactly π/2 camera y = target.y >0. So safe. Set max 1.52. `minPolarAngle = 0.05` avoid gimbal? not required. Could set 0.02.
Need "not on fire": trees min radius 8, okay.
Let's write a polished scene:
- Fire stones ring and scattered stones.
- Tree line maybe denser farther. Count 65. Use positions ring 8-60. But 65*12? maybe okay. Use 44 to be safe. Requirement 30. Could do 48.
Tree height: group scale xz 1, y scale random. Need ground placement and not intersect. Since base at y=0.
Rock ring: Dodecahedron radius 0.4; some rotated and scaled. Use 10 ring around radius 1.7. Scattered 12 around clearing.
Log seats: 4 logs at angles [0, PI/2, PI, -PI/2] radius 2.8. Use cylinder radius 0.2 length 2.3. Orientation tangent: create log geometry rotateZ(PI/2); then group.rotation.y = angle + PI/2? Let's test: local axis X. group.rotation.y = angle. If position at angle (cos angle, sin angle), tangent direction is (-sin angle, 0, cos angle) for world X? Vector X after Y rotation angle: (cos angle,0,-sin angle). For tangent we want perpendicular to radial: radial=(cos,0,sin); tangent=(-sin,0,cos). X rotated by Y angle gives (cos,0,-sin), not tangent unless angle shifted by PI/2: if group.rotation.y = angle + PI/2, X becomes (cos(a+pi/2),0,-sin(a+pi/2))=(-sin,0,-cos?) Actually cos(a+π/2)=-sin a; -sin(a+π/2)=-cos a -> (-sin,0,-cos), opposite? Close. Use angle + Math.PI/2. Not critical if logs radial? Let's make random tangent: `group.rotation.y = angle + Math.PI/2 + (Math.random()-0.5)*0.5;`.
Maybe logs not seat but lying around. Good.
Firewood pile: use geometry `new THREE.CylinderGeometry(0.1, 0.12, 1.2, 5);` rotateZ(PI/2); position y=0.12. Add 4 logs at angle 0,π/3,2π/3,π? Maybe one vertical? Better simple. Add 2 crossed.
Ember core: `new THREE.Mesh(new THREE.IcosahedronGeometry(0.22,0), new THREE.MeshBasicMaterial({color:0xff3b10, transparent:true, opacity:0.7, blending:THREE.AdditiveBlending, depthWrite:false}))` position y=0.25.
Maybe ground near fire should be slightly darker? Vertex colors.
Let's compute ground colors: Use a temporary color. Avoid new Color per vertex heavy? 5k vertices, okay. But use `new THREE.Color()` inside loop maybe okay. Simpler.
```
const groundColor = new THREE.Color();
for i:
const r = Math.hypot(x,z);
const t = groundY;
const baseHue = 0.27 + Math.sin(x*0.13 + z*0.17)*0.03;
groundColor.setHSL(baseHue, 0.35, 0.08 + t*0.008 + Math.sin(x*0.42)*0.004);
const warm = Math.max(0, 1-r/9);
groundColor.lerp(new THREE.Color(0x2a1b14), warm*0.35); // brown near fire
colors[i*3]=groundColor.r; ...
```
But `new THREE.Color(0x2a1b14)` inside loop heavy. Define `const warmColor = new THREE.Color(0x2d1f16);` before. Use `groundColor.lerp(warmColor, warm*0.35);`
Need after colors, `pos.needsUpdate=true; color.needsUpdate=true; computeVertexNormals`.
Potential issue: `geometry.rotateX(-Math.PI/2)` before iterating positions. Yes.
Stars: generate 1200 points. For colors: random brightness, some blue/yellow. `new THREE.Color().setHSL(0.55+rand*0.05, 0.6, 0.7+rand*0.2)` etc.
Fog and background: choose 0x050a16, fog 0x050a16? If same, distant objects vanish into background. But requirement fog fades trees. Use scene.fog = new THREE.FogExp2(0x070f20, 0.016). Background = 0x04070f. Fog slightly different, horizon. Could choose same to avoid band. But moon and stars unaffected. Maybe set background to fog color for consistency: 0x070f1f. At top stars show. Fine.
Need maybe add horizon glow: a large cylinder with gradient? Not necessary. But could add a subtle fire glow dome: Points? No.
Potential bug with `MeshBasicMaterial({ fog: false })` property is supported? Yes material has fog boolean. Good.
Potential bug: `controls.update(dt)` with autoRotate and damping. Good.
Let's draft code.
Need avoid using `Math.random` causing different but okay.
Maybe use seeded random? not necessary.
Check import: `import * as THREE from 'three';` and OrbitControls.
Let's write in final only html code block.
But need think of edge cases.
Sandbox iframe: import map allowed only. No network other than CDN. Good.
No console errors: Need check all THREE API names.
Three r170:
- `THREE.SRGBColorSpace` yes.
- `renderer.outputColorSpace = THREE.SRGBColorSpace;` yes.
- `OrbitControls` import.
- `THREE.Clock` yes.
- `mesh.material.opacity` yes.
- `ConeGeometry(radius, height, radialSegments, heightSegments, openEnded)` yes.
- `DodecahedronGeometry(radius, detail)` yes.
- `IcosahedronGeometry(radius, detail)` yes.
- `CylinderGeometry(radiusTop, radiusBottom, height, radialSegments, heightSegments, openEnded)` yes.
- `Geometry.rotateZ` method yes on BufferGeometry.
- `geometry.translate` method yes.
- `geometry.toNonIndexed()` yes.
- `geometry.deleteAttribute('normal')` yes.
- `geometry.setAttribute` yes.
- `geometry.computeVertexNormals` yes.
- `geometry.computeBoundingSphere` yes.
- `THREE.BufferAttribute` yes.
- `THREE.PointsMaterial` has `fog` yes.
- `THREE.LightShadow` `bias`, `normalBias` yes? In r170 normalBias exists on Shadow? I think yes. If not, setting undefined property no error. Fine.
- `pointLight.shadow.camera.near/far` yes for point shadow (it has PerspectiveCamera). Good.
Need maybe set `pointLight.castShadow = true;` yes.
Performance: updating flame vertices 7* maybe 100 vertices per frame, okay. But each flame mesh geometry toNonIndexed ConeGeometry with radialSegments=6 heightSegments=3 -> positions maybe 6*? small.
Let's define flame update to avoid per-frame allocations. In `addFlame` store base Float32Array. In `animate` call `updateFlame(flame, time)`. Use arrays.
Code snippets:
```
function addFlame(radius, height, color, opacity, sway, lift, phase, x=0,z=0){
let geo = new THREE.ConeGeometry(radius, height, 6, 3, true);
geo.translate(0, height/2, 0);
geo = geo.toNonIndexed();
geo.deleteAttribute('normal');
const base = new Float32Array(geo.attributes.position.array);
const mat = new THREE.MeshBasicMaterial({color, transparent:true, opacity, blending:THREE.AdditiveBlending, depthWrite:false, side:THREE.DoubleSide, fog:false});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(x, 0, z);
mesh.renderOrder = 2;
mesh.frustumCulled = false;
mesh.userData = {base, height, sway, lift, phase, speed: 1+Math.random()*0.8};
flameGroup.add(mesh);
flames.push(mesh);
}
```
Fog false for flames? They are close; fog would make them dim. Better false. Additive with fog might darken? Use false.
Update:
```
function updateFlame(mesh, t){
const pos = mesh.geometry.attributes.position;
const arr = pos.array;
const base = mesh.userData.base;
const h = mesh.userData.height;
const phase = mesh.userData.phase;
const sway = mesh.userData.sway;
const lift = mesh.userData.lift;
const speed = mesh.userData.speed;
for (let i=0; i<pos.count; i++) {
const i3=i*3;
const x0=base[i3], y0=base[i3+1], z0=base[i3+2];
const hf = Math.max(0, Math.min(1, y0/h));
const amp = sway * (0.25 + hf);
const tt = t * speed;
arr[i3] = x0 + Math.sin(tt*8.1 + phase + y0*4.2 + z0*2.3)*amp + Math.sin(tt*15.3 + phase*1.7 + y0*6.1)*amp*0.35;
arr[i3+1] = y0 + Math.sin(tt*10.7 + phase*0.9 + x0*5.3 + z0*4.7)*lift*hf;
arr[i3+2] = z0 + Math.cos(tt*9.3 + phase*1.2 + y0*3.8 + x0*2.1)*amp + Math.cos(tt*17.1 + phase*2.2 + y0*5.2)*amp*0.35;
}
pos.needsUpdate=true;
// also pulse scale slightly
const pulse = 0.94 + 0.08*Math.sin(t*speed*12.5+phase*2);
mesh.scale.set(pulse*(1+0.04*Math.sin(t*11+phase)), 0.92+0.16*Math.sin(t*speed*14+phase*3), pulse*(1+0.04*Math.cos(t*9+phase)));
mesh.rotation.y = Math.sin(t*0.9+phase)*0.4;
}
```
Potential scaling after vertex position modifies? okay.
Need opacity update:
```
const fireFlicker = ...;
for (flame of flames) { flame.material.opacity = flame.userData.baseOpacity * (0.7 + 0.3*fireFlicker); }
```
Store baseOpacity. Use `mesh.userData.baseOpacity = opacity`.
Sparks class:
```
class SparkSystem {
constructor(count){
const positions = new Float32Array(count*3);
const colors = new Float32Array(count*3);
const baseColors = new Float32Array(count*3);
const velocities = new Float32Array(count*3);
const life = new Float32Array(count);
const maxLife = new Float32Array(count);
for i: // init hidden
positions[i*3+1] = -1000;
life[i]=0;
velocities[i*3+1]=0;
geometry = new THREE.BufferGeometry(); set attributes...
material = new THREE.PointsMaterial({ size:0.12, vertexColors:true, transparent:true, blending:THREE.AdditiveBlending, depthWrite:false, sizeAttenuation:true, fog:false });
points = new THREE.Points(geometry, material); points.frustumCulled=false;
this.count=count;
}
respawn(i, time){
const a = Math.random()*Math.PI*2;
const r = Math.random()*0.35;
const ix=i*3;
positions[ix] = Math.cos(a)*r + (Math.random()-0.5)*0.2;
positions[ix+1] = 0.55 + Math.random()*0.5;
positions[ix+2] = Math.sin(a)*r + ...;
const up = 1.2 + Math.random()*1.8;
velocities[ix] = Math.cos(a)*0.35 + (Math.random()-0.5)*0.35;
velocities[ix+1] = up;
velocities[ix+2] = Math.sin(a)*0.35 + ...;
life[i] = maxLife[i] = 0.8 + Math.random()*1.8;
const heat = Math.random();
// baseColors: yellow orange
const c = new THREE.Color().setHSL(0.07 + heat*0.07, 1, 0.55 + Math.random()*0.25);
baseColors[ix]=c.r; ...
}
update(dt,time){
for i:
if (life[i] <= 0) respawn;
else:
ix...
positions[ix] += velocities[ix]*dt + Math.sin(time*3+i)*0.0005? Need smooth.
positions[ix+1] += velocities[ix+1]*dt;
positions[ix+2] += velocities[ix+2]*dt;
velocities[ix] += Math.sin(time*5 + i*0.37)*0.45*dt;
velocities[ix+1] -= 0.9*dt;
velocities[ix+2] += Math.cos(time*4.3 + i*0.21)*0.45*dt;
// slow horizontal wind from fire maybe
life[i] -= dt;
const ratio = Math.max(0, life[i]/maxLife[i]);
colors[ix] = baseColors[ix] * ratio * ratio;
colors[ix+1] = baseColors[ix+1] * ratio;
colors[ix+2] = baseColors[ix+2] * ratio;
if (ratio <=0) { hide: positions[ix+1]=-100; life=0; }
needsUpdate.
}
}
```
Need positions/velocities accessible within update. Store arrays as properties.
Maybe spawning all initially can burst. Initialize with random life:
```
for i: life[i]=Math.random()*2.5; maxLife[i]=2.5; positions y=-1000; velocities random? But update will respawn when life<=0? If life>0 no position; hidden. Better spawn half. Could set life negative for first few? Simpler: on first update, we need stagger: for i, life[i] = -Math.random()*2.5, maxLife[i]=2.5. But respawn condition if life<=0 and we need maybe respawn gradually not all. We can include spawn budget:
```
this.spawnBudget = 0;
update: this.spawnBudget += dt*35; for i if life<=0 && spawnBudget>1 respawn... else keep.
```
But if all inactive initially hidden, budget spawns ~35/sec. Good. Need not update velocity until active.
```
if (life[i] <= 0) { if (this.spawnBudget > 1) { respawn; this.spawnBudget -= 1; } }
else update.
```
But if respawn sets life=maxLife, then next frame life>0. Need hide inactive positions. In respawn, set active. Good. But if no budget, stays hidden. Initial hidden. Good.
But life array for inactive all 0, spawnBudget accumulates. For first frame budget 0, maybe next frame .016*35=.56, no spawn until 2 frames. Okay. Could set budget=count/2. Or call spawn first 40 on init for immediate sparks. Since screenshot 3s, by then many. Good.
Implementation:
```
constructor(count) { ... for i: life[i]=0; positions[ix+1]=-100; ... this.spawnBudget = count*0.5; }
update(dt,time) { this.spawnBudget = Math.min(this.spawnBudget + dt*35, 25); for i... if life<=0 && this.spawnBudget>=1 { respawn; this.spawnBudget-=1; } else if life>0 { ... } }
```
Need when respawn, positions set to fire. Good.
Could set spawn rate ~60 sparks/sec with count 180. Good.
Potential issue: velocities horizontal too high; sparks fly far. Keep small.
Moon: add `moon.position.copy(moonDir.multiplyScalar(280));` Then directional light position same direction but maybe not at distance? Directional light uses direction from position to target (0,0,0). Add `moonLight.position.copy(moonDir).multiplyScalar(50); moonLight.target.position.set(0,0,0); scene.add(moonLight.target);` Good.
Need avoid moon being affected by fog? MeshBasicMaterial fog false. Directional not.
Stars: maybe add slight twinkle by animating material.size? not necessary. Could update star positions? no.
Need maybe add ground receiveShadow and material. If vertex colors, `new THREE.MeshStandardMaterial({ vertexColors:true, roughness:1, metalness:0, flatShading:true });` Good.
Tree materials with flatShading true. Use shadows.
Need renderer pixel ratio. Good.
Let's consider visual quality: low-poly forest night can be too dark. Ensure enough ambient and hemisphere so scene visible in screenshot with point light. At night, dark but fire illuminates. Stars and moon. Need avoid pure black; background dark blue. Good.
Maybe add subtle warm glow around fire using a large transparent sphere? A `Sprite` or point? Could use `fireGlow = new THREE.Mesh(new THREE.SphereGeometry(1.6, 12, 12), new THREE.MeshBasicMaterial({color:0xff7722, transparent:true, opacity:0.06, blending:Additive, depthWrite:false, fog:false}))`. But might block shadows? Transparent no cast. Good. Maybe add two glow layers. This enhances first paint.
Add glow sphere:
```
const glow = new THREE.Mesh(new THREE.SphereGeometry(1.2, 12, 8), new THREE.MeshBasicMaterial({color:0xff6611, transparent:true, opacity:0.08, depthWrite:false, blending:THREE.AdditiveBlending, fog:false}));
glow.position.y=0.7; glow.renderOrder=1; scene.add(glow);
```
Animate scale with flicker. But can wash out. Use low opacity.
Firelight shadow only from opaque; glow not.
Need logs and rocks not intersect flames. okay.
Potential issue with autoRotate and OrbitControls: if `controls.enableDamping = true;` and `controls.autoRotate = true`, user interaction can't pan; okay.
Need maybe disable controls during start? no.
Let's produce final code.
Double-check syntax and variable names.
Pseudo final:
```
const canvas = renderer.domElement;
document.body.appendChild(canvas);
const controls = new OrbitControls(camera, canvas);
```
No canvas id. Good.
Use `const scene = new THREE.Scene();`
`scene.background = new THREE.Color(0x05080f);`
`scene.fog = new THREE.FogExp2(0x07111f, 0.015);`
Renderer:
```
const renderer = new THREE.WebGLRenderer({ antialias:true, powerPreference:'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio,2));
renderer.setSize(window.innerWidth,window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
```
Camera: `new THREE.PerspectiveCamera(55, innerWidth/innerHeight, 0.1, 1000); camera.position.set(9,6,8);`
Controls target: `controls.target.set(0,1.1,0); controls.enableDamping=true; controls.dampingFactor=0.07; controls.enablePan=false; controls.minDistance=5; controls.maxDistance=60; controls.minPolarAngle=0.05; controls.maxPolarAngle=Math.PI*0.49; controls.autoRotate=true; controls.autoRotateSpeed=0.55; controls.update();`
Event listeners for idle. Need if controls autoRotate false, resume.
Ground code:
```
function noiseHeight(x,z){...}
function groundY(x,z){...}
const groundGeo = new THREE.PlaneGeometry(140,140,78,78);
groundGeo.rotateX(-Math.PI/2);
const posAttr = groundGeo.attributes.position;
const colAttr = new THREE.BufferAttribute(new Float32Array(posAttr.count*3),3);
const c = new THREE.Color(); const warm = new THREE.Color(0x352319);
for i: ... posAttr.setY(i,y); color set.
groundGeo.setAttribute('color', colAttr);
groundGeo.computeVertexNormals();
const groundMat = new THREE.MeshStandardMaterial({vertexColors:true, roughness:1, metalness:0, flatShading:true});
const ground = new THREE.Mesh(groundGeo, groundMat); ground.receiveShadow=true; scene.add(ground);
```
Need when setting colAttr from c.r etc. Colors linear or sRGB? Three color attribute is linear? `THREE.Color.setHSL` gives sRGB? With color management? In r170, color attributes expected linear? If `renderer.outputColorSpace=sRGB` and `THREE.ColorManagement.enabled=true` by default, setting `.r` directly is linear? Actually `THREE.Color` methods assume sRGB? Hmm. For BufferGeometry colors, if ColorManagement enabled, color values from materials are converted; attributes are raw linear? It's complicated. For simplicity, don't use vertexColors or use standard color? If colors not matching, scene may be dark. Could avoid vertexColors; use uniform color. But nice. Maybe use `colAttr.setXYZ(i, c.r, c.g, c.b)` without worrying. With ColorManagement, `c.setHSL` maybe sRGB but `.r` linear? Need check: Since r152, `Color.setHSL` has colorSpace parameter default SRGB? It converts to linear? I think `Color` stores linear if `ColorManagement.enabled`. The methods convert. So okay. Or simpler use `c.copy(...).convertSRGBToLinear()`? Not needed.
Maybe avoid vertexColors to reduce complexity. But vertex colors enhance low-poly. Use them, likely okay. Need `groundMat.color = new THREE.Color(0xffffff)` default. Good.
If vertex color too dark? We set lightness 0.09; okay.
Tree placement code:
```
function makeTree(){ const g=new THREE.Group(); ... }
const treePositions=[];
for (let t=0; t<48; t++){
let x,z, ok=false;
for(let a=0;a<20 && !ok;a++){ const ang=Math.random()*Math.PI*2; const r=8+Math.random()*45; x=Math.cos(ang)*r; z=Math.sin(ang)*r; ok=true; for(p of treePositions){...} if(ok){treePositions.push([x,z]); const tree=makeTree(); tree.position.set(x,groundY(x,z)-0.06,z); scene.add(tree); } }
if(!ok) ... maybe add fewer. Could if not ok, push anyway with radius min? Simpler use no separation? For 48, random ring maybe overlaps. But okay. Need ensure count at least 30: If rejection, count may lower. Do: if not ok, use position anyway if t<30? Better place without separation for first 35. Use attempts, if fails break; if tree count <35 continue? Simpler no separation? Random placement can overlap but low-poly trees overlap maybe okay. But requirement random around clearing; overlapping okay. To ensure 48, skip separation or use grid. We can do simple with max 8 attempts; if not accepted, force add. Good.
```
Tree make:
```
function makeTree(){
const g = new THREE.Group();
const trunk = new THREE.Mesh(trunkGeo, trunkMats[(Math.random()*trunkMats.length)|0]);
trunk.castShadow=true; trunk.receiveShadow=true;
g.add(trunk);
const leafCount = 3 + (Math.random()*2|0); // 3-4
let y = 1.25 * (0.8 + Math.random()*0.2);
for(let i=0;i<leafCount;i++){
const mesh = new THREE.Mesh(foliageGeo, foliageMats[...]);
const size = 1.45 - i*0.25 + Math.random()*0.2;
const height = 1.25 + Math.random()*0.35;
mesh.scale.set(size, height, size);
mesh.position.y = y + i*0.55; // maybe base of cones
mesh.rotation.y = Math.random()*Math.PI;
mesh.castShadow=true; mesh.receiveShadow=true;
g.add(mesh);
}
const s = 0.9 + Math.random()*1.1;
g.scale.set(s, s*(0.85+Math.random()*0.55), s);
return g;
}
```
Geometry trunk: `const trunkGeo = new THREE.CylinderGeometry(0.18, 0.28, 1.5, 5); trunkGeo.translate(0,0.75,0);` Cone `const foliageGeo = new THREE.ConeGeometry(1,1,6,1); foliageGeo.translate(0,0.5,0);`
Then cone position y = trunk height? `g.position` base. Use `mesh.position.y = trunkHeight + i*height*0.65` maybe. With geometry base at 0, if set y to desired bottom. Simpler:
```
const trunkHeight = 1.4;
for i:
const coneH=1.3;
const coneR=1.2 - i*0.28;
mesh.scale.set(coneR, coneH, coneR);
mesh.position.y = 0.7 + i*0.8; // bottom starts at y=0.7? cone base at local 0, mesh scale not affected? position y bottom.
```
If trunk mesh scale 1, trunk height 1.4, base at 0; foliage bottom y=0.8. Good.
Maybe set `mesh.position.y = 0.9 + i*0.75`.
Need group scale affects shadows and tree height. Good.
Rocks:
```
const rockMats=[...gray]
function addRock(x,z,size, yBase=groundY(x,z)){ const rock=new THREE.Mesh(rockGeo, rockMats[rand]); rock.scale.set(size*(0.7+rand*0.6), size*(0.4+rand*0.6), size*(0.7+rand*0.6)); rock.position.set(x,yBase+size*0.2,z); rock.rotation.set(rand,rand,rand); rock.castShadow=true; rock.receiveShadow=true; scene.add(rock); }
// ring
for i=0;i<12;i++ angle=i/12*2PI + random; r=1.8+Math.random()*0.35; x=cos*r; z=sin*r; addRock(x,z,0.22+Math.random()*0.22)
// scattered
for i=0;i<18;i++ r=4+Math.random()*35; addRock...
```
Logs:
```
const logGeo = new THREE.CylinderGeometry(0.22,0.25,2.4,6,1,false); logGeo.rotateZ(Math.PI/2);
const logMats=[brown]
function addLog(x,z,angle,len=2.4, radius=0.24){ const mesh=new THREE.Mesh(logGeo,logMats[rand]); mesh.scale.set(len/2.4, radius/0.24, radius/0.24); mesh.position.set(x, groundY(x,z)+radius*0.92, z); mesh.rotation.y=angle; mesh.castShadow=true; mesh.receiveShadow=true; scene.add(mesh); }
// seats
for i=0;i<5;i++ angle=Math.PI*2*i/5; r=3.0+Math.random()*0.5; x=... z=... addLog(x,z, angle+Math.PI/2+(Math.random()-0.5)*0.6)
```
If logGeo rotated z, length along X. scale x for length, y/z radius. Good.
Firewood:
```
const stickGeo = new THREE.CylinderGeometry(0.08,0.11,1.2,5); stickGeo.rotateZ(Math.PI/2);
for i 4: angle=...; r=0.25; x=cos*r z=sin*r; add stick y=0.12? scale 0.8. Use logs crossing.
```
Maybe use logMats with darker scorched. Add `charredMat` dark.
Fire:
```
const fireGroup = new THREE.Group(); scene.add(fireGroup);
// flameGroup
const flameGroup = new THREE.Group(); flameGroup.position.y=0.0; fireGroup.add(flameGroup);
// add flames at fireGroup? If flames base local y=0, placed in group at y=0.15.
```
Set flame positions in flameGroup with y maybe 0.1? Our flame geometry base y=0 local; group y=0.15. Good.
Point light:
```
const fireLight = new THREE.PointLight(0xff9955, 80, 30, 2); fireLight.position.set(0,1.2,0); fireLight.castShadow=true; fireLight.shadow.mapSize.set(1024,1024); fireLight.shadow.camera.near=0.8; fireLight.shadow.camera.far=28; fireLight.shadow.bias=-0.001; fireLight.shadow.normalBias=0.02; scene.add(fireLight);
```
Need decay parameter in constructor? `new THREE.PointLight(color, intensity, distance, decay)` yes. Good.
Add glow sphere.
Sparks.
Stars/moon.
Animate loop.
Potential issue: `flameGroup.add(mesh)` and flame update modifies local positions; group y=0.15. Good.
Need baseOpacity for flames:
```
mesh.userData.baseOpacity=opacity;
```
Need update opacity with flicker. `const flick = 0.86+...;` Then base.
Need store light base intensity. `const baseLightIntensity=80;`
Need maybe point light color shift: warmer when flicker high: `fireLight.color.setHSL(0.07+0.03*flame, 0.9, 0.55)`? Could change. Not necessary but nice. Use base color.
Let's implement `flicker = 0.82 + 0.16*Math.sin(time*11) + 0.10*Math.sin(time*27+1.7) + 0.07*Math.sin(time*41+0.4);` Add random 0.05. Clamp 0.35-1.25. Then `fireLight.intensity = baseLightIntensity * flicker;`
Flames opacity: `flame.material.opacity = flame.userData.baseOpacity * Math.min(1.1, 0.7 + flicker*0.4);` But if baseOpacity already, okay.
Flame pulse uses time.
Sparks update with flicker maybe spawn rate: `sparkSystem.spawnRate = 35*flicker;` Use in update? Not necessary. Could pass.
Need `updateFlame` uses `mesh.userData.speed` but base positions unchanged. Good.
Potential issue: `mesh.scale.set(...)` changes after geometry positions. Since base positions are scaled too; okay.
Now code final. Let's test mentally for errors.
Import map script exactly. Use `type="module"` not with import map.
Potential issue: CSS `font-family: system-ui, -apple-system, ...` okay.
Potential issue: `window.addEventListener('resize', onResize)` uses innerWidth. Good.
Potential issue: If no interaction, autoRotate continues. Good.
Potential issue: Stars `sizeAttenuation:false` but geometry positions far, material size pixels; good. PointsMaterial needs `transparent:true`, `opacity`. Good.
Potential issue: PointsMaterial `fog:false` accepted? yes.
Potential issue: `starGeo.computeBoundingSphere()` for static, good.
Potential issue: `spark.points.geometry.attributes.color.needsUpdate = true;` Need access attributes. Good.
Let's write SparkSystem class robust:
```
class SparkSystem {
constructor(scene, count) {
this.count=count; this.positions=new Float32Array(count*3); ... this.life=new Float32Array(count); this.maxLife=new Float32Array(count); this.budget=count*0.5;
for i: this.positions[i*3+1]=-100; this.life[i]=0;
this.geometry = new THREE.BufferGeometry();
this.geometry.setAttribute('position', new THREE.BufferAttribute(this.positions,3).setUsage(THREE.DynamicDrawUsage));
this.geometry.setAttribute('color', new THREE.BufferAttribute(this.colors,3).setUsage(THREE.DynamicDrawUsage));
const material = new THREE.PointsMaterial(...);
this.points = new THREE.Points(this.geometry, material); this.points.frustumCulled=false; scene.add(this.points);
}
respawn(i, time) { ... }
update(dt,time,flicker) { this.budget = Math.min(this.budget + dt*(35*flicker), 30); for i... if (this.life[i] <= 0) { if (this.budget >= 1) { this.respawn(i,time); this.budget -=1; } } else { ... } this.geometry.attributes.position.needsUpdate=true; this.geometry.attributes.color.needsUpdate=true; }
}
```
When respawn, set colors to baseColors. Need if inactive, colors black? We hide position -100; additive points may still visible if color nonzero. When inactive life=0 and hidden, colors should be 0. In respawn set colors. In update if life<=0 set `this.colors[i*3+1]=0`? Good. But if hidden y=-100, not visible anyway due frustumCulled false but could render outside camera? If color black, invisible. Set colors to 0 when inactive. In respawn, set base.
In update for active: after life becomes <=0, hide:
```
if (life[i] <=0) { this.positions[ix+1]=-100; this.colors[ix]=0;... }
```
Then next frame respawn maybe.
Sparks respawn velocity:
```
const angle = Math.random()*Math.PI*2; const rad=Math.random()*0.25;
ix = i*3;
this.positions[ix] = Math.cos(angle)*rad;
this.positions[ix+1] = 0.45 + Math.random()*0.6;
this.positions[ix+2] = Math.sin(angle)*rad;
this.velocities[ix] = Math.cos(angle)*(0.2+Math.random()*0.55) + (Math.random()-0.5)*0.3;
this.velocities[ix+1] = 1.5+Math.random()*1.8;
this.velocities[ix+2] = Math.sin(angle)*(0.2+Math.random()*0.55) + ...;
const life = 0.7+Math.random()*2.0;
this.life[i]=this.maxLife[i]=life;
const color = new THREE.Color().setHSL(0.06+Math.random()*0.08, 1, 0.55+Math.random()*0.3); // But Color creation in respawn many. Could avoid using temp color property this.tmpColor.
```
Define `this.tmpColor = new THREE.Color();`.
In update movement:
```
const px = ix;
this.positions[px] += this.velocities[px]*dt;
this.positions[px+1] += this.velocities[px+1]*dt;
this.positions[px+2] += this.velocities[px+2]*dt;
this.velocities[px] += Math.sin(time*5 + i*0.31)*0.5*dt;
this.velocities[px+1] -= 1.35*dt;
this.velocities[px+2] += Math.cos(time*4.4 + i*0.27)*0.5*dt;
// wind
this.velocities[px] += Math.sin(time*0.7)*0.03; // no dt? maybe.
this.life[i] -= dt;
const ratio = Math.max(0, this.life[i]/this.maxLife[i]);
const fade = ratio*ratio;
this.colors[px] = this.baseColors[px] * fade;
this.colors[px+1] = this.baseColors[px+1] * (0.5 + ratio*0.5);
this.colors[px+2] = this.baseColors[px+2] * (0.4 + ratio*0.6);
if (this.life[i] <=0) { hide }
```
Potential issue: if velocity y becomes negative, sparks fall; but life short, okay. Add gravity not too strong.
Need maybe size variation? PointsMaterial one size. Could create multiple sizes? no. Could use size 0.08. Good.
Let's think about shadows: point light shadows for trees maybe only near fire, but tree trunks/leaves at 9+ radius. Shadow far 28 includes trees up to 28. Good.
Potential issue: point light shadow with `far=28` may ignore tree shadows beyond, okay. Need shadow map near 0.8: logs at y=0.2, fire light at y=1.2 distance to ground <1.0? Near 0.8 ok. Maybe set near=0.1 to include logs. Use `near=0.5`, far=30. Bias. Good.
Potential issue: shadow acne on ground with point light. Bias -0.0005, normalBias 0.02. Good.
Maybe add `renderer.shadowMap.autoUpdate = true;` default.
Need `scene.add(moon);` `moon.castShadow=false;` MeshBasic no shadow. Good.
Potential issue: Directional moon light shadows? castShadow false. Good.
Let's consider fog: `THREE.FogExp2` affects point light? No. Material fog by default true for ground/trees. Stars/moon/sparks/flames fog false. Good.
Need maybe background: `scene.background = new THREE.Color(0x03060d);` Fog color slightly blue. Could cause ground far not fully background. Good.
Potential issue: `camera.far=1000`, stars at radius 300, moon 280, good. FogExp2 would affect standard materials at 300, but stars/moon basic fog false. Ground plane extends 140 far beyond? Plane is 140, diagonal 99? Actually PlaneGeometry 140 width -> edges ±70, far ~99; fog fades. Good.
Potential issue: If camera maxDistance 60, target center, camera y min = target.y + distance*cos(maxPolar) ~ 1.1 + 60*0.031 = 2.96 above ground. Good.
Potential issue: UI panel pointer-events none, but controls attached to renderer.domElement; clicks on panel pass through? pointer-events none means canvas gets events. Good.
Need no external fonts. Good.
Let's refine tree colors and materials.
```
const trunkMats=[];
const trunkBase = new THREE.MeshStandardMaterial({color:0x3d2a1e, flatShading:true, roughness:1});
for i=0..5 { const m=trunkBase.clone(); m.color.offsetHSL(0, (i-2)*0.02, (i-2)*0.02); trunkMats.push(m); }
const foliageMats=[];
const foliageBase = new THREE.MeshStandardMaterial({color:0x1e3d25, flatShading:true, roughness:1});
for i=0..7 { const m=foliageBase.clone(); m.color.offsetHSL((i-3)*0.015, 0, (i-3)*0.01); foliageMats.push(m); }
```
Need `offsetHSL` changes color from base linear; okay.
For rocks:
```
const rockGeo = new THREE.DodecahedronGeometry(0.45,0);
for i=0..4 { const m = new THREE.MeshStandardMaterial({color:new THREE.Color(0x666872).clone().offsetHSL(...), flatShading:true, roughness:1}); rockMats.push(m); }
```
`new THREE.Color(0x...).offsetHSL` mutates. Good.
Logs materials: dark brown. Charred logs maybe black.
Need maybe log geo reused but scaling for char. okay.
Potential issue: `DodecahedronGeometry` detail 0 gives 12 pentagon faces, low-poly. Good.
Potential issue: `IcosahedronGeometry` for moon? Use Sphere.
Add sky? no.
Now, code final with comments. The prompt asks to build, no explanation. Final must exactly one fenced html block. Good.
Double-check no missing semicolons? We can use concise.
Let's write final code carefully.
Possible improvement: use `document.getElementById('hint').style.opacity`? CSS animation handles. Need if user interacts maybe hide faster? Not necessary. But can add:
```
function hideHint(){ const hint=document.getElementById('hint'); if(hint) hint.style.animation='none', hint.style.opacity='0'; }
window.addEventListener('pointerdown', hideHint, {once:true});
window.addEventListener('keydown', hideHint, {once:true});
```
CSS also fades after 8s. Good.
Need ensure `once:true` for keydown. If user clicks, hint hides. Screenshot 3s before interaction, visible.
Potential issue: if hint hidden on first pointerdown, good.
Auto resume timer code:
```
let autoTimer;
function pauseAuto(){ controls.autoRotate=false; clearTimeout(autoTimer); }
function resumeLater(){ clearTimeout(autoTimer); autoTimer=setTimeout(()=>{controls.autoRotate=true;},4500); }
controls.addEventListener('start', pauseAuto);
controls.addEventListener('end', resumeLater);
```
If user presses key? no controls event? Not needed.
If controls.autoRotate false and user does nothing, end event may not trigger? Actually when no interaction, autoRotate true initially; no start/end. If user starts drag, start, end. Good. If user clicks, start/end. If only key, maybe not. Not important.
Need initial `hideHint` after 8s via CSS. Good.
Potential issue: `controls.update(dt)` with autoRotate true uses delta? If dt undefined? We'll pass.
Now final code.
Let's think about colors: If using MeshBasicMaterial flames with fog:false and additive, they'll be visible. But point light intensity maybe too low? Let's calculate with physically correct: r170 uses physically correct by default? `renderer.useLegacyLights` removed? In r155, physically correct lights default. PointLight intensity 80 candela, at distance 1m ~80 lux? Standard material albedo 0.05 -> radiance low, but tone mapping. For nearby ground at 3m, 80/9=8.9. Should be visible. Ambient 0.2, hemi 0.3. Good.
Maybe point light distance=30 too short; distant trees beyond 30 no fire light but moon. Good. Set 35.
Need maybe `decay=1.8` not 2? Use 2.
Flames base at y=0.2. Light at y=1.0, so firelight shadows logs under flames. Good.
Potential issue: The fire light itself casts shadows from point, but flames are additive MeshBasic, no shadows. Good.
Let's write `addFlame` side tongues positions:
```
addFlame(0.46,1.55,0xff5b22,0.28,0.13,0.08,0.0);
addFlame(0.36,1.28,0xff8d32,0.40,0.10,0.06,1.3);
addFlame(0.24,1.00,0xffcc70,0.58,0.07,0.05,2.4);
addFlame(0.13,0.70,0xfff5cf,0.78,0.05,0.04,3.8);
const tongueOffsets=[[-0.28,0.08],[0.25,-0.06],[0.0,0.3],[0.05,-0.28]];
for ... addFlame(0.18,0.78,0xff7728,0.32,0.09,0.05,i*1.7, ox,0,oz);
```
addFlame needs x,z args, maybe y. Use `pos.set(x, y, z)` with y=0.
In `addFlame`, parameter `y=0.25`. Group y=0.1. Fine.
Need `flameGroup.position.y=0.15` so base y around. If flame y=0.25, group -> 0.4. Good. Light y=1.0 above.
Potential issue: `MeshBasicMaterial` with side DoubleSide and open cone: inside visible. Good.
Now ground vertex color: Use `setHSL` and `lerp`. Need `warmColor` reused. In loop, `groundColor.lerp(warmColor, warm * 0.42)`. But if `groundColor` already changed, next iteration resets. Good.
```
const warmColor = new THREE.Color(0x312017);
for i...
const hue = 0.29 + Math.sin(x*0.07+z*0.11)*0.04;
const sat = 0.36 + Math.cos(x*0.13-z*0.18)*0.08;
const light = 0.10 + groundY*0.006 + Math.sin(x*0.32+z*0.28)*0.004;
color.setHSL(hue, Math.max(0.1, sat), Math.max(0.02, light));
color.lerp(warmColor, Math.max(0,1-r/8)*0.3);
colAttr.setXYZ(i, color.r, color.g, color.b);
```
Need if groundY negative? okay.
Maybe ground too brown near fire. Good.
Potential issue: `color.setHSL` lightness range maybe too low; with tone mapping and point light maybe okay.
Add small clearing path? no.
Let's consider tree shadows: Point light from fire will cast long shadows away from fire. Logs/rocks near. Good.
Need maybe add a few fallen leaves? no.
Let's now craft final code in one block. Ensure no text outside.
Double-check all variables defined: `trunkGeo`, `foliageGeo`, `logGeo`, `stickGeo`, `rockGeo`, `flames`, `fireLight`, `glowMesh`, `sparkSystem`, `clock`, `baseLightIntensity`, `flameGroup`.
In `addFlame`, use `const mesh = new THREE.Mesh(geo, mat);` but `geo` variable assigned from let? Fine.
Potential issue: `geo = geo.toNonIndexed();` If original has attribute groups? no.
Potential issue: `geo.deleteAttribute('normal');` MeshBasicMaterial might still try to access normals for morph? no.
Potential issue: `geo.attributes.position.array` after toNonIndexed exists. Good.
Potential issue: If `toNonIndexed` returns geometry with position attribute but no uv? okay.
Potential issue: `mesh.frustumCulled=false`. Good.
In `animate`, need update `glowMesh.scale` with flicker:
```
const gs = 0.95 + 0.12*flicker; glowMesh.scale.set(gs, gs*0.85, gs); glowMesh.material.opacity=0.05 + flicker*0.05;
```
Maybe base.
Point light color maybe `fireLight.color.setHSL(0.07 + 0.02*flicker, 0.85, 0.55);` but setting color each frame. Good.
Need not call `renderer.render` before controls.update? do after.
`const dt = Math.min(clock.getDelta(), 0.033);` Then `time += dt;`.
Potential issue: if tab hidden, dt cap okay.
Let's ensure CSS hint animation works: If hideHint sets inline opacity 0 with transition? CSS `animation: hintFade 1s ease 7s forwards`. If we override animation none and opacity 0. Good.
CSS:
```
@keyframes hintFade { from{opacity:1} to{opacity:0} }
#hint { animation: hintFade 1.2s ease 7.5s forwards; }
```
Need maybe title visible. Good.
Let's final.