Mistral Large 4
by MistralReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 4 min 2 s
- Cost
- $0.046
- Output tokens
- 21,782
- Spent thinking
- 15,635 tokens
- Speed
- 90.2 tok/s
- Console errors
- 0
- First token after
- 917 ms
- First visible text after
- 3 min 15 s
- Prompt tokens
- 577
- Code size
- 17.1 KB
- Finish reason
- stop
- Served by
- Mistral
- Model id
mistralai/mistral-large-4-0- Reasoning sent
{"effort":"high"}- Max output
- 262,144 tokens
- Generated
- Nov 12, 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 · 450 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Night Campfire — Low-Poly Forest</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background: #070b16; }
canvas { display: block; }
#hud {
position: fixed; top: 14px; left: 16px; pointer-events: none; user-select: none;
color: #ffd9a8; text-shadow: 0 1px 8px rgba(0,0,0,.85);
font-family: Georgia, 'Times New Roman', serif;
}
#hud h1 { margin: 0; font-size: 18px; font-weight: normal; letter-spacing: 3px; }
#hud p { margin: 3px 0 0; font-size: 11px; letter-spacing: 1px; opacity: .75;
font-family: Verdana, sans-serif; }
#vignette {
position: fixed; inset: 0; pointer-events: none;
background: radial-gradient(ellipse at 50% 45%, transparent 55%, rgba(0,0,12,.55) 100%);
}
@media (max-width: 480px) {
#hud h1 { font-size: 14px; }
#hud p { font-size: 10px; }
}
</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 id="hud">
<h1>✦ NIGHT CAMPFIRE ✦</h1>
<p>drag to orbit · scroll to zoom</p>
</div>
<div id="vignette"></div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
/* ---------- renderer / scene / camera ---------- */
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x0e1321, 0.026);
const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 1500);
camera.position.set(9, 5.2, 11);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;
document.body.appendChild(renderer.domElement);
/* ---------- night sky dome ---------- */
const sky = new THREE.Mesh(
new THREE.SphereGeometry(700, 32, 16),
new THREE.ShaderMaterial({
side: THREE.BackSide,
depthWrite: false,
vertexShader: `
varying vec3 vP;
void main(){ vP = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: `
varying vec3 vP;
void main(){
float h = normalize(vP).y;
vec3 c = mix(vec3(0.055,0.075,0.13), vec3(0.006,0.010,0.028), smoothstep(-0.05, 0.55, h));
gl_FragColor = vec4(c, 1.0);
}`
})
);
sky.renderOrder = -10;
scene.add(sky);
/* ---------- stars ---------- */
const STAR_COUNT = 900;
const starPos = new Float32Array(STAR_COUNT * 3);
const starCol = new Float32Array(STAR_COUNT * 3);
const cA = new THREE.Color(0xffffff), cB = new THREE.Color(0xafc4ff), cC = new THREE.Color(0xffe6b8);
for (let i = 0; i < STAR_COUNT; i++) {
const v = new THREE.Vector3(Math.random() * 2 - 1, Math.random() * 2 - 1, Math.random() * 2 - 1);
if (v.lengthSq() < 0.001) v.set(0, 1, 0);
if (v.y < 0.04) v.y = 0.04 + Math.random() * 0.08;
v.normalize().multiplyScalar(620);
starPos.set([v.x, v.y, v.z], i * 3);
const c = Math.random() < 0.7 ? cA : (Math.random() < 0.5 ? cB : cC);
const b = 0.55 + Math.random() * 0.45;
starCol.set([c.r * b, c.g * b, c.b * b], i * 3);
}
const starGeo = new THREE.BufferGeometry();
starGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3));
starGeo.setAttribute('color', new THREE.BufferAttribute(starCol, 3));
const starMat = new THREE.PointsMaterial({
size: 2.2, sizeAttenuation: false, vertexColors: true,
transparent: true, opacity: 0.9, depthWrite: false, fog: false
});
const stars = new THREE.Points(starGeo, starMat);
stars.frustumCulled = false;
scene.add(stars);
/* ---------- helpers ---------- */
function makeRadialTexture(inner, mid, outer) {
const c = document.createElement('canvas');
c.width = c.height = 128;
const g = c.getContext('2d');
const grad = g.createRadialGradient(64, 64, 2, 64, 64, 64);
grad.addColorStop(0, inner);
grad.addColorStop(0.35, mid);
grad.addColorStop(1, outer);
g.fillStyle = grad;
g.fillRect(0, 0, 128, 128);
return new THREE.CanvasTexture(c);
}
/* ---------- moon ---------- */
const moonDir = new THREE.Vector3(-0.38, 0.42, -0.55).normalize();
const moonPos = moonDir.clone().multiplyScalar(640);
const moon = new THREE.Mesh(
new THREE.SphereGeometry(17, 24, 16),
new THREE.MeshBasicMaterial({ color: 0xf7f2df, fog: false })
);
moon.position.copy(moonPos);
scene.add(moon);
const moonGlow = new THREE.Sprite(new THREE.SpriteMaterial({
map: makeRadialTexture('rgba(255,246,220,1)', 'rgba(255,240,200,0.35)', 'rgba(255,236,190,0)'),
blending: THREE.AdditiveBlending, transparent: true, opacity: 0.85, depthWrite: false, fog: false
}));
moonGlow.position.copy(moonPos);
moonGlow.scale.setScalar(150);
scene.add(moonGlow);
/* ---------- lights ---------- */
scene.add(new THREE.HemisphereLight(0x33456e, 0x0b0e08, 0.55));
const moonLight = new THREE.DirectionalLight(0x8fa8e0, 0.5);
moonLight.position.copy(moonDir.clone().multiplyScalar(200));
scene.add(moonLight);
const fireLight = new THREE.PointLight(0xff7a2a, 70, 45, 2);
fireLight.position.set(0, 1.0, 0);
fireLight.castShadow = true;
fireLight.shadow.mapSize.set(1024, 1024);
fireLight.shadow.camera.near = 0.2;
fireLight.shadow.camera.far = 30;
fireLight.shadow.bias = -0.004;
fireLight.shadow.normalBias = 0.03;
scene.add(fireLight);
/* ---------- ground (low-poly, vertex-colored) ---------- */
const groundGeo = new THREE.PlaneGeometry(160, 160, 52, 52);
groundGeo.rotateX(-Math.PI / 2);
{
const p = groundGeo.attributes.position;
const colors = new Float32Array(p.count * 3);
const grass = new THREE.Color(0x2c4a26), dirt = new THREE.Color(0x4a3826),
scorchC = new THREE.Color(0x14100c), moss = new THREE.Color(0x1c331e);
for (let i = 0; i < p.count; i++) {
const x = p.getX(i), z = p.getZ(i), r = Math.hypot(x, z);
const h = Math.sin(x * 0.09) * Math.cos(z * 0.08) * 1.5
+ Math.sin(x * 0.21 + 2.0) * Math.cos(z * 0.17 + 1.0) * 0.55
+ Math.sin(x * 0.45 + 0.7) * Math.cos(z * 0.4 + 2.2) * 0.18;
const flat = THREE.MathUtils.smoothstep(r, 3.5, 10); // keep clearing flat
p.setY(i, h * flat);
const c = grass.clone().lerp(Math.random() < 0.5 ? moss : dirt, Math.random() * 0.7);
c.offsetHSL(0, 0, (Math.random() - 0.5) * 0.04);
if (r < 4.5) c.lerp(scorchC, (1 - r / 4.5) * 0.9);
colors.set([c.r, c.g, c.b], i * 3);
}
groundGeo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
groundGeo.computeVertexNormals();
}
const ground = new THREE.Mesh(groundGeo, new THREE.MeshStandardMaterial({
vertexColors: true, flatShading: true, roughness: 1, metalness: 0
}));
ground.receiveShadow = true;
scene.add(ground);
/* scorch mark under the fire */
const scorch = new THREE.Mesh(
new THREE.CircleGeometry(1.15, 24),
new THREE.MeshBasicMaterial({ color: 0x0a0806, transparent: true, opacity: 0.75, depthWrite: false })
);
scorch.rotation.x = -Math.PI / 2;
scorch.position.y = 0.02;
scorch.renderOrder = 1;
scene.add(scorch);
/* ---------- campfire ---------- */
const fire = new THREE.Group();
scene.add(fire);
const stoneMat = new THREE.MeshStandardMaterial({ color: 0x8a8d94, flatShading: true, roughness: 0.9 });
const barkMat = new THREE.MeshStandardMaterial({ color: 0x5a4028, flatShading: true, roughness: 1 });
/* ring of stones */
for (let i = 0; i < 9; i++) {
const a = i / 9 * Math.PI * 2 + Math.random() * 0.4;
const r = 0.62 + Math.random() * 0.12;
const s = 0.14 + Math.random() * 0.16;
const st = new THREE.Mesh(new THREE.DodecahedronGeometry(s, 0), stoneMat);
st.position.set(Math.cos(a) * r, s * 0.45, Math.sin(a) * r);
st.scale.set(s * (0.8 + Math.random() * 0.5), s * (0.5 + Math.random() * 0.4), s * (0.8 + Math.random() * 0.5));
st.rotation.set(Math.random() * 3, Math.random() * 3, Math.random() * 3);
st.castShadow = st.receiveShadow = true;
fire.add(st);
}
/* teepee of logs */
for (let i = 0; i < 6; i++) {
const a = i / 6 * Math.PI * 2 + 0.26;
const base = new THREE.Vector3(Math.cos(a) * 0.5, 0, Math.sin(a) * 0.5);
const apex = new THREE.Vector3(0, 0.72, 0);
const dir = apex.clone().sub(base);
const len = dir.length();
const log = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.095, len, 6), barkMat);
log.position.copy(base).add(apex).multiplyScalar(0.5);
log.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.normalize());
log.castShadow = log.receiveShadow = true;
fire.add(log);
}
/* animated low-poly flames */
const flameDefs = [
{ r: 0.36, h: 1.15, c: 0xff2e00, o: 0.50 },
{ r: 0.27, h: 0.92, c: 0xff7300, o: 0.65 },
{ r: 0.18, h: 0.66, c: 0xffb62e, o: 0.80 },
{ r: 0.10, h: 0.45, c: 0xfff0b8, o: 0.95 }
];
const flames = [];
for (const d of flameDefs) {
const g = new THREE.ConeGeometry(d.r, d.h, 7, 1);
g.translate(0, d.h / 2, 0); // anchor base at origin so scaling grows upward
const m = new THREE.Mesh(g, new THREE.MeshBasicMaterial({
color: d.c, transparent: true, opacity: d.o,
blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide
}));
m.position.y = 0.22;
m.rotation.y = Math.random() * 6;
fire.add(m);
flames.push(m);
}
/* ember core */
const ember = new THREE.Mesh(
new THREE.IcosahedronGeometry(0.17, 0),
new THREE.MeshBasicMaterial({ color: 0xff6a00, blending: THREE.AdditiveBlending, transparent: true, opacity: 0.9, depthWrite: false })
);
ember.position.y = 0.3;
fire.add(ember);
/* warm glow sprite */
const fireGlow = new THREE.Sprite(new THREE.SpriteMaterial({
map: makeRadialTexture('rgba(255,190,90,0.9)', 'rgba(255,120,40,0.35)', 'rgba(255,80,20,0)'),
blending: THREE.AdditiveBlending, transparent: true, opacity: 0.9, depthWrite: false
}));
fireGlow.position.y = 0.7;
fireGlow.scale.setScalar(2.2);
fire.add(fireGlow);
/* ---------- trees (>= 30, random, away from the fire) ---------- */
const trunkGeo = new THREE.CylinderGeometry(0.16, 0.28, 2.4, 5);
const trunkMat = new THREE.MeshStandardMaterial({ color: 0x4a3423, flatShading: true, roughness: 1 });
const coneGeos = [
new THREE.ConeGeometry(1.35, 2.3, 6),
new THREE.ConeGeometry(1.00, 2.0, 6),
new THREE.ConeGeometry(0.62, 1.7, 6)
];
const folMats = [0x1e3d26, 0x274a2c, 0x183421].map(c =>
new THREE.MeshStandardMaterial({ color: c, flatShading: true, roughness: 1 })
);
function makeTree(x, z, sMin, sMax) {
const t = new THREE.Group();
const trunk = new THREE.Mesh(trunkGeo, trunkMat);
trunk.position.y = 1.2;
trunk.castShadow = trunk.receiveShadow = true;
t.add(trunk);
const fm = folMats[(Math.random() * folMats.length) | 0];
const ys = [2.5, 3.6, 4.6];
coneGeos.forEach((g, j) => {
const m = new THREE.Mesh(g, fm);
m.position.y = ys[j];
m.castShadow = m.receiveShadow = true;
t.add(m);
});
const s = sMin + Math.random() * (sMax - sMin);
t.scale.set(s, s * (0.9 + Math.random() * 0.3), s);
t.position.set(x, 0, z);
t.rotation.y = Math.random() * Math.PI * 2;
scene.add(t);
}
let placed = 0, guard = 0;
while (placed < 34 && guard < 800) { // clearing trees
guard++;
const a = Math.random() * Math.PI * 2;
const r = 8.5 + Math.random() * 26;
const x = Math.cos(a) * r, z = Math.sin(a) * r;
if (Math.hypot(x, z) < 7.5) continue; // never on the fire
makeTree(x, z, 0.75, 1.7);
placed++;
}
for (let i = 0; i < 22; i++) { // distant silhouettes in the fog
const a = Math.random() * Math.PI * 2;
const r = 38 + Math.random() * 18;
makeTree(Math.cos(a) * r, Math.sin(a) * r, 1.1, 2.0);
}
/* ---------- scattered rocks & fallen logs ---------- */
for (let i = 0; i < 6; i++) {
const a = Math.random() * Math.PI * 2, r = 3.2 + Math.random() * 6.5;
const s = 0.25 + Math.random() * 0.5;
const rock = new THREE.Mesh(new THREE.DodecahedronGeometry(s, 0), stoneMat);
rock.position.set(Math.cos(a) * r, s * 0.4, Math.sin(a) * r);
rock.rotation.set(Math.random() * 3, Math.random() * 3, Math.random() * 3);
rock.scale.y = 0.6 + Math.random() * 0.4;
rock.castShadow = rock.receiveShadow = true;
scene.add(rock);
}
const fallenLogGeo = new THREE.CylinderGeometry(0.09, 0.11, 1.6, 6);
for (let i = 0; i < 4; i++) {
const a = Math.random() * Math.PI * 2, r = 2.2 + Math.random() * 2.2;
const log = new THREE.Mesh(fallenLogGeo, barkMat);
log.position.set(Math.cos(a) * r, 0.1, Math.sin(a) * r);
log.rotation.order = 'YXZ';
log.rotation.y = Math.random() * Math.PI * 2;
log.rotation.z = Math.PI / 2;
log.castShadow = log.receiveShadow = true;
scene.add(log);
}
/* ---------- sparks (particle system) ---------- */
const SPARKS = 90;
const sPos = new Float32Array(SPARKS * 3);
const sCol = new Float32Array(SPARKS * 3);
const sVel = new Array(SPARKS);
const sLife = new Float32Array(SPARKS);
const sMax = new Float32Array(SPARKS);
function resetSpark(i, scattered) {
const a = Math.random() * Math.PI * 2, r = Math.random() * 0.24;
sPos[i * 3] = Math.cos(a) * r;
sPos[i * 3 + 1] = scattered ? 0.5 + Math.random() * 3.2 : 0.5 + Math.random() * 0.3;
sPos[i * 3 + 2] = Math.sin(a) * r;
sVel[i] = new THREE.Vector3((Math.random() - 0.5) * 0.55, 0.9 + Math.random() * 1.5, (Math.random() - 0.5) * 0.55);
sMax[i] = 0.8 + Math.random() * 1.7;
sLife[i] = scattered ? Math.random() * sMax[i] : sMax[i];
}
for (let i = 0; i < SPARKS; i++) resetSpark(i, true);
const sparkGeo = new THREE.BufferGeometry();
sparkGeo.setAttribute('position', new THREE.BufferAttribute(sPos, 3));
sparkGeo.setAttribute('color', new THREE.BufferAttribute(sCol, 3));
const sparks = new THREE.Points(sparkGeo, new THREE.PointsMaterial({
size: 0.13, vertexColors: true, transparent: true, opacity: 1,
blending: THREE.AdditiveBlending, depthWrite: false, sizeAttenuation: true
}));
sparks.frustumCulled = false;
scene.add(sparks);
/* ---------- orbit controls (camera stays above the ground) ---------- */
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1.1, 0);
controls.enableDamping = true;
controls.dampingFactor = 0.06;
controls.minDistance = 4;
controls.maxDistance = 60;
controls.minPolarAngle = 0.12;
controls.maxPolarAngle = Math.PI * 0.495; // never below the horizon/ground
controls.autoRotate = true;
controls.autoRotateSpeed = 0.9;
let idleTimer = null;
controls.addEventListener('start', () => {
controls.autoRotate = false;
if (idleTimer) clearTimeout(idleTimer);
});
controls.addEventListener('end', () => {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => { controls.autoRotate = true; }, 6000);
});
controls.addEventListener('change', () => {
const t = controls.target;
t.y = THREE.MathUtils.clamp(t.y, 0.4, 8);
const hr = Math.hypot(t.x, t.z);
if (hr > 12) { t.x *= 12 / hr; t.z *= 12 / hr; }
});
/* ---------- resize ---------- */
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
/* ---------- animation loop ---------- */
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
const t = clock.elapsedTime;
/* flames: flicker, sway, spin */
for (let i = 0; i < flames.length; i++) {
const f = flames[i];
const sway = Math.sin(t * (7 + i * 1.9) + i * 2.4);
f.scale.set(
1 + 0.16 * sway + 0.06 * Math.sin(t * 15 + i),
1 + 0.32 * Math.sin(t * (6 + i * 1.3) + i * 1.7) + 0.18 * Math.sin(t * 11.7 + i * 3),
1 + 0.16 * Math.cos(t * (8.3 + i) + i)
);
f.rotation.y = t * (1.2 + i * 0.35) + i * 1.3;
f.position.x = Math.sin(t * 2.6 + i * 2.1) * 0.035;
f.position.z = Math.cos(t * 2.2 + i * 1.4) * 0.035;
f.material.opacity = flameDefs[i].o * (0.8 + 0.25 * Math.sin(t * 12 + i * 4.2));
}
ember.scale.setScalar(1 + 0.25 * Math.sin(t * 9.3));
/* flickering warm point light */
const flick = Math.sin(t * 11.3) * 0.5 + Math.sin(t * 23.7 + 1.3) * 0.3 + Math.sin(t * 5.1 + 4.0) * 0.2;
fireLight.intensity = 70 * (1 + 0.22 * flick) + Math.random() * 5;
fireLight.position.y = 0.95 + 0.06 * Math.sin(t * 8.8);
fireGlow.material.opacity = 0.75 + 0.2 * flick;
fireGlow.scale.setScalar(2.0 + 0.35 * flick + 0.1 * Math.sin(t * 13.7));
/* sparks rise, wobble and fade to black (additive => invisible) */
for (let i = 0; i < SPARKS; i++) {
sLife[i] -= dt;
if (sLife[i] <= 0) resetSpark(i, false);
const v = sVel[i];
v.y = Math.max(0.35, v.y - 0.35 * dt);
v.x += Math.sin(t * 4.1 + i * 1.3) * dt * 0.5;
v.z += Math.cos(t * 3.3 + i * 2.1) * dt * 0.5;
sPos[i * 3] += v.x * dt;
sPos[i * 3 + 1] += v.y * dt;
sPos[i * 3 + 2] += v.z * dt;
const f = Math.max(sLife[i] / sMax[i], 0), b = f * f;
sCol[i * 3] = b;
sCol[i * 3 + 1] = 0.55 * b * f;
sCol[i * 3 + 2] = 0.12 * b * f * f;
}
sparkGeo.attributes.position.needsUpdate = true;
sparkGeo.attributes.color.needsUpdate = true;
/* gentle star twinkle */
starMat.opacity = 0.75 + 0.18 * Math.sin(t * 0.9) + 0.07 * Math.sin(t * 2.3);
controls.update();
renderer.render(scene, camera);
}
animate();
</script>
</body>
</html>Reasoning trace (15,635 tokens)
We need to build a single HTML file with Three.js via import map, low-poly campfire scene at night. Requirements:
- Ground with low-poly rocks and logs around campfire center
- >=30 trees randomly placed (not on fire)
- Animated flames + warm point light flickering with shadows
- Sparks particle system
- Dark night sky with stars and moon, fog
- OrbitControls with limits (minPolarAngle/maxPolarAngle so camera stays above ground — set maxPolarAngle < PI/2, minDistance/maxDistance)
- Renderer fills window, resize handling
- Self-contained, no external besides the import map URLs
- Must look good at 1280x800, adapt to 375px
- Something visible on first paint (no blocking start message needed since it's not a game — orbit controls work with mouse; keyboard not needed)
- No console errors
Let me write the code.
Key elements:
1. Import map exactly as given.
2. Module script importing three and OrbitControls from 'three/addons/controls/OrbitControls.js'.
3. Scene, fog (FogExp2 dark blue), sky: use a large sphere with shader or just scene background color + stars (Points) + moon (circle mesh with emissive, maybe a sprite). I'll do stars as Points with random positions on a dome, moon as a sphere with emissive material far away, plus maybe a moon glow sprite (canvas-generated texture — allowed since inline).
4. Ground: a circular or plane geometry, low-poly: use PlaneGeometry with segments and displace vertices randomly, flatShading, vertex colors maybe. Keep it simple: CircleGeometry with many segments, displace y randomly except near center, MeshStandardMaterial flatShading dark green/brown.
5. Campfire center: logs arranged in a teepee (cylinders rotated), stones ring (icosahedrons/dodecahedrons flattened), flames: several cone/plane meshes with additive material, animated scale/rotation in tick. Point light orange, intensity flickers via noise, castShadow true, shadow map size 1024.
6. Sparks: Points with custom positions updated each frame rising with wobble, fade via size/opacity — use PointsMaterial with vertexColors? Simpler: use ShaderMaterial or just PointsMaterial with transparent and animate positions, recycle when y > threshold. Additive blending, orange color. Could vary color per spark via vertex colors and update alpha... PointsMaterial doesn't support per-point opacity easily; use color attribute to fade to black with additive blending (additive blending makes black invisible). That works nicely.
7. Trees: >=30, random positions in ring radius 8..30, not within ~3 of center. Each tree: trunk cylinder + 2-3 cones (low poly, flatShading), dark green. Random scale/rotation. Maybe use InstancedMesh for performance but simple meshes fine for 30 trees (~100 meshes fine). Could merge but keep simple. To cast shadows they need castShadow; 30 trees fine.
8. Rocks and logs around fire: few rocks (dodecahedron), logs lying (cylinder) scattered.
9. OrbitControls: target at fire (0,1,0), enableDamping, maxPolarAngle = Math.PI/2 - 0.05 (stay above ground), minDistance 3, maxDistance 60, minPolarAngle maybe 0.15. Also pan limited? Could disable pan or clamp target. Keep pan enabled but maybe limit. Requirement: limits so camera stays above ground — maxPolarAngle < PI/2 does that.
10. Resize handler updating camera aspect and renderer size.
11. Renderer: antialias, shadowMap enabled PCFSoft, setPixelRatio min(devicePixelRatio,2), outputColorSpace default. Fill window: width/height 100%.
12. Animation loop with clock; flame animation: scale pulses, rotation; light intensity flicker = base + noise; sparks update; controls.update; render.
Also add subtle ambient/hemisphere light dim blue for night visibility, and maybe a faint directional moonlight with shadows? Requirement says point light casts shadows. Moon directional could also cast but keep one shadow light for perf. Add dim hemisphere.
Flames: create 3-4 flame meshes: ConeGeometry with flat shading? Better: use custom "flame" shape — a cone with distorted vertices, MeshBasicMaterial with color gradient? Simple approach: 3 cones nested, colors from yellow core to orange to red, additive blending, transparent, animated scale (y scale oscillates, x/z squish), slight rotation. Plus inner glow sprite at base. That looks decent.
Alternative: use planes with a canvas-generated flame texture (radial gradient) — but cones are fine and truly low-poly.
Actually a nicer flame: use a cone geometry, then in tick set scale.y = base*(1+0.3*sin(t*10+i)), rotation.y += ...; material opacity flickers. Additive blending on black background looks glowy.
Sparks: BufferGeometry with N=80 points, positions attribute, colors attribute. Each spark: pos, vel (up + random x/z), life. Update: pos += vel*dt; vel.y += slight? Actually sparks rise and slow: vel.y decreases; wobble x. When life ends or y>8, reset at fire top with new random vel, life. Color: lerp from yellow to dark red based on life, additive so fading to black = invisible. size ~ 0.15, sizeAttenuation true.
Stars: 800 points on sphere radius 400 (inside far plane 1000), sizes 1-2, white/blueish, slight twinkle via opacity? PointsMaterial single opacity; could twinkle by size — skip or animate material size slightly. Keep static, fine. Maybe two star layers different sizes.
Moon: sphere radius 12 at position like (-120, 140, -200), MeshBasicMaterial color pale #f4f1de, plus glow: sprite with canvas radial gradient texture, additive, scale 60. Also a subtle directional light from moon direction, very dim blue, no shadow (or with shadow? only point light needs shadows). Keep directional intensity 0.15.
Sky: scene.background = new Color(0x050914) or gradient via large sphere with shader. Simple: background color + fog FogExp2(0x0a1020, 0.028). Fog fades distant trees — yes.
Ground: CircleGeometry radius 60, 48 segments? Actually PlaneGeometry(120,120,40,40), displace vertices: y = noise-ish random * amplitude, but flatten near center (within r<6 keep y=0) and maybe raise edges slightly. Use simple pseudo-random with smoothing? Just random per vertex creates jagged; use a function like sum of sines for gentle hills: y = 1.2*sin(x*0.08)*cos(z*0.07) + 0.4*sin(x*0.3+1.7)*cos(z*0.23+0.5), zero near center via smoothstep factor. flatShading true. Vertex colors: mix dark green and brown based on height/random for variation. Use MeshStandardMaterial vertexColors true, flatShading.
Compute normals after displacement.
Trees: function makeTree(): group with trunk (CylinderGeometry(0.18,0.3,h,5)) brown, foliage: 2-3 cones stacked (ConeGeometry(r, h, 6)) dark green with slight color variance. castShadow on all. Random position: angle random, radius 9 + rand*22 (up to 31), ensure distance from center > 7. Also avoid overlapping fire ring. 34 trees.
Rocks: 7 rocks in ring radius 1.1-1.6 around fire: DodecahedronGeometry(rand 0.18-0.35), gray, flatShading, random rotation/scale y squashed, castShadow/receiveShadow.
Logs around fire: 5-6 cylinders arranged teepee: each log length 1.1 radius 0.09, leaning: position at ring radius 0.35, tilt ~ 65-75 deg from horizontal pointing to center top. Compute orientation: create cylinder along Y, then rotate to lean. Easiest: for i in 6: angle = i/6*2PI; log group at (cos*r, 0.05, sin*r), rotation.z = tilt, rotation.y = -angle+PI/2... Let me think: cylinder axis is Y. We want it leaning toward center, top at center. Place at angle a, offset r=0.32 from center, tilt around the axis perpendicular. Set mesh.rotation.y = -a (so local +? ) then rotation.z = tilt? Order of rotations in three.js default 'XYZ'. Simpler: use quaternion setFromUnitVectors? Or: create log, set position (cos(a)*0.34, 0.10, sin(a)*0.34), then rotation.set(0, 0, tilt) after rotating y by ... hmm.
Alternative simpler: logs lying flat in a small pile next to fire plus a few leaning. Requirement: "logs around a campfire" — a teepee of logs is classic. Let me do: for each log:
```
const log = new Mesh(cylGeo, barkMat);
log.position.set(Math.cos(a)*0.35, 0.09, Math.sin(a)*0.35);
log.rotation.y = -a + Math.PI/2; // orient local X toward center?
log.rotation.z = Math.PI/2 - leanAngle...
```
Getting fiddly. Easier approach: build log pointing up (along Y), then apply quaternion: rotate around axis (sin? ). Use: log.quaternion.setFromUnitVectors(new Vector3(0,1,0), dir) where dir = normalized vector from base position to apex (0, 0.55, 0) minus base position... but setFromUnitVectors gives shortest arc; position log so its center is midpoint between base and apex: base = (cos a * r, 0, sin a * r), apex = (0, h, 0). mid = base/2 with y h/2. dir = (apex - base).normalize(). Then position = mid. Length of cylinder should equal |apex-base|. That works cleanly.
```
const base = new THREE.Vector3(Math.cos(a)*0.42, 0, Math.sin(a)*0.42);
const apex = new THREE.Vector3(0, 0.62, 0);
const dir = apex.clone().sub(base);
const len = dir.length();
const geo = new THREE.CylinderGeometry(0.07, 0.09, len, 6);
const log = new Mesh(geo, mat);
log.position.copy(base).add(apex).multiplyScalar(0.5);
log.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0), dir.normalize());
```
Good. Plus 2-3 fallen logs on ground nearby: cylinder rotated z=PI/2 lying, random position ring 2-4.
Fire light: PointLight(0xff7722, intensity, distance 30, decay 2), position (0, 0.9, 0), castShadow, shadow.mapSize 1024, shadow.camera.near 0.1 far 25, bias -0.005. Flicker: intensity = 60? In three r155+ uses physically correct lighting by default (r170: useLegacyLights removed default false). PointLight intensity in candela; with decay 2, need high intensity like 30-80. I'll set intensity ~ 40 and flicker ±. Let's use intensity base 50, flicker = 50*(0.75+0.25*noise) plus small random. Also light color slightly varies. Distance 40, decay 2.
Also add a small warm PointLight? One is fine.
Ambient: HemisphereLight(0x223355 sky, 0x0a0f0a ground, 0.35). Plus dim DirectionalLight moon 0x8899ff 0.25 from moon position, no shadow.
Shadow: renderer.shadowMap.enabled = true, type PCFSoftShadowMap. Ground receiveShadow, trees cast+receive, rocks cast, logs cast.
Flames: group at fire. 3 cones:
- outer: ConeGeometry(0.32, 0.9, 7), color 0xff4400, transparent opacity 0.85, blending Additive, depthWrite false
- mid: ConeGeometry(0.22, 0.7, 6), color 0xff8800
- inner: ConeGeometry(0.13, 0.5, 6), color 0xffee88
Position base at y 0.25 (above logs base). Each frame: t = clock elapsed; flame.scale.set(1+0.15*sin(t*9+i*2), 1+0.3*sin(t*7+i*1.3)+0.2*sin(t*13), 1+0.15*cos(t*8)); rotation.y = t*2 + i; material opacity flicker. Also move slightly x/z sway. Also inner core bright.
Also ember glow: a small sphere/plane at base with emissive? Add a PointLight already. Add "glow" sprite at fire with additive radial gradient, scale pulsing.
Also maybe a subtle smoke? Not required.
Ground fire scorch: a dark circle mesh under fire (CircleGeometry radius 0.9, black, opacity 0.6) slightly above ground y=0.02 to avoid z-fighting, polygonOffset maybe. Good detail.
Camera: start at (8, 5, 10) looking at (0, 1, 0). fov 55. near 0.1 far 1000.
Controls: target (0,1.2,0); enableDamping dampingFactor 0.06; minDistance 4; maxDistance 55; maxPolarAngle = Math.PI * 0.495 (just under horizon so camera stays above ground); minPolarAngle = 0.1; maybe enablePan true but limit? Screen-space pan default. Could set controls.maxTargetRadius? Not in 0.170? There is maxTargetRadius in newer versions... Actually OrbitControls has minTargetRadius/maxTargetRadius added around r169? Not sure. Safer: on 'change' clamp target y between 0.5 and 8 and length horizontal <= 12. Or just disable pan (enablePan = false) — simplest and keeps target at fire. But panning is nice; clamping in change event:
```
controls.addEventListener('change', ()=>{ const t=controls.target; t.y=THREE.MathUtils.clamp(t.y,0.5,6); const r=Math.hypot(t.x,t.z); if(r>10){t.x*=10/r;t.z*=10/r;} });
```
Fine.
Resize: window.addEventListener('resize', ...) update camera.aspect, camera.updateProjectionMatrix(), renderer.setSize(innerWidth, innerHeight).
HTML/CSS: body margin 0, overflow hidden, canvas block. Add a small overlay title? "Campfire — drag to orbit" hint, subtle, top-left, fades? Keep minimal: small text bottom-left "drag to orbit • scroll to zoom". Nice touch, doesn't block. First paint shows scene immediately.
Pixel ratio: renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)).
Stars: generate 900 points on upper hemisphere sphere radius 450, y>0. Use BufferGeometry positions, PointsMaterial({color:0xffffff, size:1.6, sizeAttenuation:false? At radius 450 with attenuation size would be tiny; use sizeAttenuation false with size 1.5-2.5 px — but PointsMaterial has single size. Use size 2, sizeAttenuation false, transparent, opacity 0.9, fog:false (material.fog = false so stars not fogged). Also second layer size 3 few bright stars. Colors slight variation via vertexColors: mix white/blue/yellow. With sizeAttenuation false, vertexColors still work.
Also twinkle: animate opacity of star material slightly sin — subtle. Fine.
Moon: sphere radius 14 at (-160, 190, -260)? Must be within far=1000 and outside fog influence — fog affects materials unless material.fog=false. MeshBasicMaterial has fog property; set fog:false for moon and glow sprite. DirectionalLight from moon direction: position (-60, 80, -100), target 0.
Moon glow sprite: canvas 128x128 radial gradient white->transparent, SpriteMaterial map, blending additive, depthWrite false, fog false, scale 90. Position same as moon slightly.
Fog: scene.fog = new THREE.FogExp2(0x0b1220, 0.022). Background color 0x070b16 slightly darker than fog so horizon blends. Actually with FogExp2, background color unaffected — set background same-ish as fog color for seamless: background 0x0b1220. Hmm night sky should be darker at zenith... Could use a big sky dome with gradient shader. Simple gradient dome: SphereGeometry(600, 32, 15) BackSide ShaderMaterial with top color #020409 and horizon #0d1626. That's nicer. fog:false not applicable to ShaderMaterial (fog only if shader includes fog chunks; it won't). Let's do gradient dome. Stars radius 550 inside dome 600. Moon at distance ~500 on dome. far plane 1200.
ShaderMaterial dome:
```
vertexShader: varying vec3 vPos; void main(){ vPos = position; gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0); }
fragmentShader: varying vec3 vPos; void main(){ float h = normalize(vPos).y; vec3 top = vec3(0.008,0.012,0.03); vec3 hor = vec3(0.05,0.075,0.13); vec3 c = mix(hor, top, smoothstep(0.0,0.5,h)); gl_FragColor = vec4(c,1.0); }
```
side: BackSide, depthWrite false. Render first (renderOrder -1) or just it's far away.
Fog color should match horizon color ~ (0.05,0.075,0.13) => 0x0d1321. FogExp2 density 0.02 — at distance 40, factor = 1-exp(-(0.02*40)^2)= 1-exp(-0.64)=0.47 — trees at 30 faded nicely. Maybe 0.018.
Ground color: dark green-brown; with fog it fades. Vertex colors: base green 0x1d3320 varying to 0x2a2418 dirt near center? Nice: near fire, scorch brown. Compute color per vertex by radius: mix.
Trees color variation: use per-tree material clone? 34 materials fine. Or vertexColors on shared geometry... simpler: create 3-4 shared foliage materials with different greens, assign randomly.
Performance: shadows with ~40 casters fine.
Sparks: N=90. positions Float32Array, colors Float32Array. Arrays for vel, life, maxLife. PointsMaterial({size:0.12, vertexColors:true, transparent:true, blending:AdditiveBlending, depthWrite:false, sizeAttenuation:true}). Update in tick with dt clamped.
Reset spark: pos = (rand*0.3-0.15, 0.7+rand*0.2, ...), vel = (rand*0.3-0.15, 1.2+rand*1.2, rand*0.3-0.15), life = maxLife = 1+rand*1.5. Update: life -= dt; pos.addScaledVector(vel, dt); vel.x += sin(t*3+seed)*dt*0.3 wobble; vel.y *= (1 - 0.15*dt)? sparks decelerate slightly; gravity slight negative? Keep rising, slow. t01 = life/maxLife; color = lerp(dark red (0.3,0.05,0.0) -> yellow (1,0.8,0.3)) * t01^2 maybe; with additive, black = invisible. Set colors[i3] = r*brightness etc. When life<=0 reset. Also needsUpdate.
Also flame light illuminates sparks? They're basic material, fine — emissive look.
Embers bed: small cluster of glowing rocks? Add a few tiny spheres with emissive orange at base, pulsing opacity — nice. Optional: 8 small icosahedrons at base with MeshBasicMaterial color orange, scale pulsing. Keep simple: one "ember core" sphere radius 0.18 color 0xff6600 additive pulsing.
Rocks ring: 8 stones.
Scattered rocks: 5 bigger rocks random at radius 4-10. Fallen logs: 3.
Also maybe fireflies? Not required; skip (or tiny green points wandering — adds life; optional. Skip to keep focused, or add — it's cheap and pretty. Requirement list is specific; extra fireflies fine but keep code manageable. I'll skip fireflies... actually a few add magic; but risk none. I'll add 12 faint drifting fireflies? Hmm, keep scope tight — skip.)
Text overlay: div with title "Night Campfire" and hint. Style: position fixed, top 16px left 16px, color rgba(255,220,180,0.85), font-family system, text-shadow. Small. Also maybe a subtle vignette via CSS radial-gradient overlay for cinematic look — pointer-events none. Good.
Now write the animate loop:
```
const clock = new THREE.Clock();
function animate(){
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
const t = clock.elapsedTime;
// flames
flames.forEach((f,i)=>{ ... });
// light flicker
const n = Math.sin(t*11.3)*0.5+Math.sin(t*23.7)*0.3+Math.sin(t*5.1)*0.2;
fireLight.intensity = 42*(1+0.28*n) + Math.random()*4;
fireLight.position.y = 0.95 + Math.sin(t*9)*0.05;
// glow sprite scale
glow.scale.setScalar(1.6+0.25*Math.sin(t*7.7)+0.1*n);
// sparks update
...
// stars twinkle
starMat.opacity = 0.75+0.2*Math.sin(t*0.8);
// controls
controls.update();
renderer.render(scene, camera);
}
```
Flame animation details:
```
flames.forEach((f,i)=>{
const s = 1 + 0.18*Math.sin(t*(8+i*1.7) + i*2.1) + 0.08*Math.sin(t*17+i);
f.scale.set(s*1.0, 1+0.35*Math.sin(t*(6.5+i)+i*1.3)+0.15*Math.sin(t*12.3), s);
f.rotation.y = t*(1.5+i*0.4) + i;
f.position.x = Math.sin(t*3+i*2)*0.03;
f.material.opacity = 0.75 + 0.2*Math.sin(t*10+i*2.7);
});
```
Materials per flame (clone) so opacity independent.
Cone origin is at center of height, so position cone at y = h/2. Set each flame mesh position.y = base + h/2 where base=0.15. When scaling y, it scales around center — flame will grow both up and down; better to anchor bottom: set geometry translated so base at 0: geo.translate(0, h/2, 0) then mesh position.y = baseY, scale grows upward. Do that.
Outer flame color 0xff3300 opacity .55, mid 0xff7a00 .7, core 0xffdd66 .9. Additive. depthWrite false. Also each flame slightly rotated x random for organic.
Also a fourth small outer red cone bigger/wider low opacity.
Ground geometry: PlaneGeometry(140,140,48,48), rotateX(-PI/2). Displace:
```
const pos = geo.attributes.position;
for i: x=pos.getX(i), z=pos.getZ(i); r=hypot(x,z);
let h = (Math.sin(x*0.09)*Math.cos(z*0.08)*1.4 + Math.sin(x*0.23+2.0)*Math.cos(z*0.19+1.0)*0.5);
const flat = THREE.MathUtils.smoothstep(r, 4, 9); // 0 near center, 1 far
pos.setY(i, h*flat);
// color
const c = ...;
```
smoothstep(x, min, max) in three MathUtils: smoothstep(x, min, max). Yes.
Colors: near center dark scorched: mix between dirt #2a2019 and grass #1c2f1c by noise; plus height tint. Use Color lerp:
```
const grass = new Color(0x24401f), dirt = new Color(0x3a2c1c), dark = new Color(0x171310);
let c = grass.clone().lerp(dirt, Math.random()*0.5);
if (r<3.2) c.lerp(dark, 1 - r/3.2 * 0.85);
else c.lerp(dirt, ...)?
```
Also add slight random brightness. Set color attribute. Material vertexColors:true, flatShading:true, roughness 1.
Wait flatShading with vertex colors: computed normals per face — fine.
receiveShadow on ground.
Trees placement: avoid center r>7.5, also avoid being too close to each other? Random may cluster; acceptable. Radius between 8 and 34, 34 trees. Also scale 0.7-1.6.
Tree function:
```
function makeTree(){
const g = new THREE.Group();
const h = 1.6+Math.random()*1.4;
const trunk = new Mesh(trunkGeo, trunkMat);
trunk.scale.y = h; // trunkGeo height 1 centered? define trunkGeo = CylinderGeometry(0.14,0.22,1,5) then position y=h/2
...
}
```
Simpler: build with fixed proportions and scale group.
trunk: CylinderGeometry(0.16, 0.26, 2.2, 5), position y 1.1.
foliage cones: ConeGeometry(1.3, 2.2, 6) at y 2.6; ConeGeometry(1.0,1.9,6) at y 3.7; ConeGeometry(0.65,1.6,6) at y 4.7. Group scale s = 0.75+rand*0.9. castShadow all meshes.
Foliage materials: array of 3: 0x1d3a24, 0x24482a, 0x183020 with flatShading. MeshStandardMaterial flatShading.
Trunk mat: 0x3a2a1c flatShading.
Positions:
```
let placed=0, attempts=0;
while(placed<34 && attempts<500){ attempts++; const a=Math.random()*Math.PI*2; const r=8+Math.random()*26; const x=Math.cos(a)*r, z=Math.sin(a)*r; if(Math.hypot(x,z)<7.5) continue; ... place }
```
r up to 34 < ground 70 fine, fog will fade outer ones (fog at 34: (0.02*34)^2=0.46, factor 0.37 — hmm not very faded; distant trees at 34 only 37% fogged. Increase density to 0.028: at 34, (0.952)^2=0.906, factor 0.595. At 60 (edge of visible ground): (1.68)^2=2.82, factor 0.94 — ground edge heavily fogged. Good: density 0.026. Let me pick 0.025. Trees at 8-34: at 8: (0.2)^2=0.04→4% fog; at 20: 0.25→22%; at 34: 0.72→51%. Nice fade. Fog color 0x0d1424 matches horizon.
Hmm but fire light distance 40 lights trees within 40 — fine.
Moon directional light: color 0x9db4ff intensity 0.35, position (-80, 120, -160). No shadow (perf) — or enable shadow for moonlit look? Point light already casts. Keep moon no shadow.
Hemisphere: sky 0x2a3a5f, ground 0x0c0f0a, intensity 0.5. Plus small AmbientLight 0x111722 0.4? Hemisphere enough.
Tone mapping: renderer.toneMapping = ACESFilmicToneMapping, exposure 1.1 — makes fire glow nicer. outputColorSpace default SRGB in r170.
Shadow settings on fireLight: castShadow true; shadow.mapSize.set(1024,1024); shadow.camera.near=0.2; shadow.camera.far=30; shadow.bias=-0.004; shadow.radius maybe (PCFSoft ignores radius). Set camera left/right/top/bottom default ±? PointLight shadow uses cube camera; near/far enough.
Camera: PerspectiveCamera(55, aspect, 0.1, 1500). position (9, 5.5, 11).
Controls target (0, 1.1, 0).
UI overlay:
```
<div id="hud"><h1>Campfire Night</h1><p>drag to orbit · scroll to zoom</p></div>
```
styled.
Also handle visibility of everything at first paint — yes, animate starts immediately.
Check OrbitControls import path: 'three/addons/controls/OrbitControls.js' — with importmap "three/addons/": ".../examples/jsm/" so resolves. Good.
Potential console error sources: none expected. Ensure no `useLegacyLights` (removed). Ensure physically correct: in r170, renderer.useLegacyLights default false and removed? In r165 default changed; r170 `useLegacyLights` removed entirely I think (deprecated r155, removed r165). Don't set it. Light intensities: PointLight with decay=2 physical units — intensity 40 gives strong local light. Directional 0.35 fine. Hemisphere 0.5 fine.
Let me also double-check `THREE.MathUtils.smoothstep(x, min, max)` signature — yes smoothstep(x, min, max).
Sparks color attribute: PointsMaterial vertexColors true uses color attribute multiplied with material color (white). Additive blending: final = src*srcAlpha? AdditiveBlending in three: srcRGB * srcAlpha? Actually AdditiveBlending = src * srcAlpha + dst (ONE, ONE? ). In three.js AdditiveBlending: blendSrc = SrcAlphaFactor, blendDst = OneFactor. With transparent true, alpha from material opacity (global) — per-point alpha not available; so fade via color to black works since src color * alpha added. opacity 1. So set colors to black when dead. Good.
Points frustumCulled = false to avoid disappearing when bounding sphere off — set geometry.boundingSphere manually or frustumCulled=false on points. Do points.frustumCulled = false.
Stars: also frustumCulled false or big bounding sphere; set frustumCulled=false.
Moon glow sprite texture via canvas:
```
function makeGlowTexture(){ const c=document.createElement('canvas'); c.width=c.height=128; const g=c.getContext('2d'); const grad=g.createRadialGradient(64,64,0,64,64,64); grad.addColorStop(0,'rgba(255,250,230,1)'); grad.addColorStop(0.25,'rgba(255,240,200,0.55)'); grad.addColorStop(1,'rgba(255,230,180,0)'); g.fillStyle=grad; g.fillRect(0,0,128,128); const tex=new THREE.CanvasTexture(c); return tex; }
```
CanvasTexture inline — allowed (no external).
Fire glow sprite similar orange.
Moon mesh: MeshBasicMaterial({color:0xf5f0dc, fog:false}) sphere 13 at (-180, 210, -300) normalized * 520 maybe. Let's place at direction: dir = new Vector3(-0.35, 0.5, -0.55).normalize().multiplyScalar(520). Sphere radius 16. Glow sprite at same pos scale 140, opacity 0.9.
Stars on sphere radius 560, only y>0? Full sphere fine (below horizon hidden by ground? ground plane finite 140 — stars below horizon visible beyond ground edge... dome horizon color covers; stars below horizon would show beyond ground plane edge at distance 70+ where fog covers ground to horizon color; star at y<0 at radius 560 would be below dome horizon line — visible against fog color, looks odd. Restrict stars to y > -0.05? Just y>0.02 hemisphere plus a few near horizon. Fine: generate with y = Math.abs(y)*0.9+... simpler: sample direction, if y<0.03 set y=0.03+rand*... Just do: y = 0.05 + Math.random()*0.95 normalized? I'll generate unit vectors and if v.y < 0.05, v.y = 0.05 + Math.random()*0.1 then normalize. OK.
Star colors: mostly white, some #bcd0ff, some #ffe9c4.
Now HUD text: also mention "Click or press a key to start"? Not needed — not a game; orbit works on click/drag directly. The instruction about keyboard applies if demo uses keyboard; we don't. But maybe add keyboard arrows to orbit? OrbitControls has listenToKeyEvents for pan with arrow keys. Not necessary. Skip to avoid needing click-focus message. Mouse works without iframe focus? Mouse events on iframe work when hovering; the note says keyboard events only after click — mouse is fine. OK.
Write final HTML. Also add `<meta viewport>`. Title.
Resize: also update on orientationchange? resize covers.
Let me also ensure no horizontal scrollbar: body overflow hidden.
Edge: `renderer.setSize(window.innerWidth, window.innerHeight)` and CSS canvas display block.
Shadow acne: bias -0.002, normalBias 0.02 maybe. Use shadow.normalBias = 0.03.
Let me now write flame geometry:
```
function flameGeo(r, h, seg){ const g = new THREE.ConeGeometry(r, h, seg, 1); g.translate(0, h/2, 0); return g; }
```
Flames:
```
const flameDefs = [
{r:0.34, h:1.05, c:0xff3300, o:0.55},
{r:0.25, h:0.85, c:0xff7b00, o:0.7},
{r:0.16, h:0.6, c:0xffc23d, o:0.85},
{r:0.09, h:0.42, c:0xfff3b0, o:0.95},
];
```
MeshBasicMaterial({color, transparent:true, opacity, blending:AdditiveBlending, depthWrite:false, side:DoubleSide}). Position group at (0, 0.28, 0). Each flame mesh y=0 (geometry base at 0). Random rotation.z slight tilt.
Scale animation anchored at base since geometry translated — good.
Also flames castShadow? Basic material with shadows — flame casting shadow would be weird; set castShadow false (default false). Light comes from point light at center anyway.
Logs teepee height 0.62 apex; flames base 0.28 inside. Good.
Ember core: sphere r 0.16 at y 0.32, MeshBasicMaterial 0xff5500 additive, pulse scale. Plus point light at 0.8.
Stones ring: 8 stones at r 0.62 angle i/8*2PI + jitter, scale random, y = 0.5*scaleY.
Fallen logs: 3 logs lying: cylinder rotated to horizontal at random pos r 2.2-3.5, rotation.y random, y = radius 0.09. Slight tilt. castShadow.
Scattered rocks: 6 at r 3.5-9, dodecahedron scaled.
Also a couple of bushes? skip.
Now sparks update code:
```
const SPARKS = 90;
const sPos = new Float32Array(SPARKS*3), sCol = new Float32Array(SPARKS*3);
const sVel = [], sLife = [], sMax = [];
for(...){ resetSpark(i, true); }
function resetSpark(i, init){
const a = Math.random()*Math.PI*2, r = Math.random()*0.22;
sPos[i*3] = Math.cos(a)*r; sPos[i*3+1] = init? 0.6+Math.random()*2.5 : 0.55+Math.random()*0.25; sPos[i*3+2]=Math.sin(a)*r;
sVel[i] = new THREE.Vector3((Math.random()-0.5)*0.5, 0.9+Math.random()*1.4, (Math.random()-0.5)*0.5);
sMax[i] = 0.9+Math.random()*1.6; sLife[i] = init? Math.random()*sMax[i] : sMax[i];
}
```
Update per frame:
```
for i: sLife[i]-=dt; if(sLife[i]<=0){resetSpark(i,false);}
const p = sVel[i]; sPos[i*3]+=p.x*dt; ... p.y -= 0.25*dt? sparks decelerate: p.y = Math.max(0.3, p.y - 0.5*dt); wobble: p.x += Math.sin(t*4+i)*dt*0.4; p.z += Math.cos(t*3.3+i*1.7)*dt*0.4;
const f = Math.max(sLife[i]/sMax[i],0); const b = f*f;
sCol[i*3] = 1.0*b; sCol[i*3+1] = 0.55*b*f? let's: r= b, g=0.6*b*f, bl=0.15*b*f*f → yellow→red→black.
```
Set needsUpdate.
Points size 0.14, sizeAttenuation true.
Also maybe smoke particles? skip.
Now, camera min polar 0.12, max polar Math.PI*0.495. minDistance 4, maxDistance 60. autoRotate? Could enable slow auto-rotate for demo liveliness — but user can drag; autoRotate pauses on interaction? It continues unless disabled on 'start' event. Add gentle autoRotate speed 0.6, and disable on user start, re-enable after idle 8s? Nice touch:
```
controls.autoRotate = true; controls.autoRotateSpeed = 0.8;
controls.addEventListener('start', ()=>{ controls.autoRotate=false; clearTimeout(idleT); });
controls.addEventListener('end', ()=>{ idleT=setTimeout(()=>controls.autoRotate=true, 6000); });
```
That makes first seconds show motion. Good.
Clamp target in change event.
DPR: min(devicePixelRatio, 2).
Now assemble full file carefully. Watch out: importmap must be exactly as given. Script type="module" after it.
Code:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Night Campfire — Low-Poly Forest</title>
<style>
html,body{margin:0;padding:0;height:100%;overflow:hidden;background:#070b16;}
canvas{display:block;}
#hud{position:fixed;top:14px;left:16px;color:#ffd9a8;pointer-events:none;font-family:Georgia,'Times New Roman',serif;text-shadow:0 1px 6px rgba(0,0,0,.8);user-select:none;}
#hud h1{margin:0;font-size:18px;font-weight:normal;letter-spacing:2px;}
#hud p{margin:2px 0 0;font-size:11px;opacity:.75;letter-spacing:1px;font-family:Verdana,sans-serif;}
#vignette{position:fixed;inset:0;pointer-events:none;background:radial-gradient(ellipse at 50% 45%, transparent 55%, rgba(0,0,10,.55) 100%);}
</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 id="hud"><h1>✦ NIGHT CAMPFIRE ✦</h1><p>drag to orbit · scroll to zoom</p></div>
<div id="vignette"></div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
...
</script>
</body>
</html>
```
JS details:
```js
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x0d1424, 0.026);
const camera = new THREE.PerspectiveCamera(55, innerWidth/innerHeight, 0.1, 1500);
camera.position.set(9, 5.2, 11);
const renderer = new THREE.WebGLRenderer({antialias:true});
renderer.setPixelRatio(Math.min(devicePixelRatio,2));
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;
document.body.appendChild(renderer.domElement);
```
Sky dome, stars, moon, lights, ground, fire, trees, rocks/logs, sparks, controls, animate, resize.
Sky dome:
```js
const sky = new THREE.Mesh(
new THREE.SphereGeometry(700, 32, 16),
new THREE.ShaderMaterial({
side: THREE.BackSide, depthWrite:false,
vertexShader:`varying vec3 vP; void main(){ vP=position; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`,
fragmentShader:`varying vec3 vP; void main(){ float h=normalize(vP).y; vec3 c=mix(vec3(0.055,0.075,0.13), vec3(0.006,0.01,0.028), smoothstep(-0.05,0.55,h)); gl_FragColor=vec4(c,1.0);}`
})
);
sky.renderOrder = -10; scene.add(sky);
```
Fog color should match horizon 0x0d1321 ≈ (0.051,0.075,0.129). Set fog 0x0e1522? Let's compute: 0.055*255=14 (0x0e), 0.075*255=19 (0x13), 0.13*255=33 (0x21) → 0x0e1321. Use fog color 0x0e1321, density 0.026.
Stars:
```js
const starCount=900, sp=new Float32Array(starCount*3), sc=new Float32Array(starCount*3);
const cA=new THREE.Color(0xffffff), cB=new THREE.Color(0xaFC4ff), cC=new THREE.Color(0xffe6b8);
for(let i=0;i<starCount;i++){
const v=new THREE.Vector3().randomDirection();
if(v.y<0.04) v.y=0.04+Math.random()*0.08;
v.normalize().multiplyScalar(620);
sp.set([v.x,v.y,v.z], i*3);
const c = Math.random()<0.7?cA:(Math.random()<0.5?cB:cC);
const b=0.55+Math.random()*0.45;
sc.set([c.r*b,c.g*b,c.b*b], i*3);
}
const starGeo=new THREE.BufferGeometry();
starGeo.setAttribute('position', new THREE.BufferAttribute(sp,3));
starGeo.setAttribute('color', new THREE.BufferAttribute(sc,3));
const starMat=new THREE.PointsMaterial({size:2.2, sizeAttenuation:false, vertexColors:true, transparent:true, opacity:0.9, depthWrite:false, fog:false});
const stars=new THREE.Points(starGeo, starMat); stars.frustumCulled=false; scene.add(stars);
```
Note Vector3.randomDirection exists in r170? randomDirection added r131? Yes exists. But randomDirection can give y negative; handled.
Moon:
```js
const moonDir = new THREE.Vector3(-0.38, 0.42, -0.55).normalize();
const moonPos = moonDir.clone().multiplyScalar(640);
const moon = new THREE.Mesh(new THREE.SphereGeometry(17, 24, 16), new THREE.MeshBasicMaterial({color:0xf7f2df, fog:false}));
moon.position.copy(moonPos); scene.add(moon);
// glow
const glowTex = makeRadialTexture('rgba(255,246,220,1)','rgba(255,240,200,0.35)','rgba(255,236,190,0)');
const moonGlow = new THREE.Sprite(new THREE.SpriteMaterial({map:glowTex, blending:THREE.AdditiveBlending, transparent:true, opacity:0.85, depthWrite:false, fog:false}));
moonGlow.position.copy(moonPos); moonGlow.scale.setScalar(150); scene.add(moonGlow);
```
makeRadialTexture:
```js
function makeRadialTexture(inner, mid, outer){
const c=document.createElement('canvas'); c.width=c.height=128;
const g=c.getContext('2d');
const grad=g.createRadialGradient(64,64,2,64,64,64);
grad.addColorStop(0,inner); grad.addColorStop(0.35,mid); grad.addColorStop(1,outer);
g.fillStyle=grad; g.fillRect(0,0,128,128);
return new THREE.CanvasTexture(c);
}
```
Lights:
```js
scene.add(new THREE.HemisphereLight(0x33456e, 0x0b0e08, 0.55));
const moonLight = new THREE.DirectionalLight(0x8fa8e0, 0.5);
moonLight.position.copy(moonDir.clone().multiplyScalar(200));
scene.add(moonLight);
const fireLight = new THREE.PointLight(0xff7a2a, 60, 45, 2);
fireLight.position.set(0, 1.0, 0);
fireLight.castShadow = true;
fireLight.shadow.mapSize.set(1024,1024);
fireLight.shadow.camera.near = 0.2;
fireLight.shadow.camera.far = 30;
fireLight.shadow.bias = -0.004;
fireLight.shadow.normalBias = 0.03;
scene.add(fireLight);
```
Intensity 60 with decay 2: at distance 1, 60; at 5, 60/25=2.4 — visible. Maybe base 80. Flicker around 70±25%. Let's base 70.
Ground:
```js
const groundGeo = new THREE.PlaneGeometry(160,160,52,52);
groundGeo.rotateX(-Math.PI/2);
{
const p = groundGeo.attributes.position;
const colors = new Float32Array(p.count*3);
const grass=new THREE.Color(0x2c4a26), dirt=new THREE.Color(0x4a3826), scorch=new THREE.Color(0x14100c), moss=new THREE.Color(0x1c331e);
for(let i=0;i<p.count;i++){
const x=p.getX(i), z=p.getZ(i), r=Math.hypot(x,z);
let h=(Math.sin(x*0.09)*Math.cos(z*0.08))*1.5 + Math.sin(x*0.21+2.0)*Math.cos(z*0.17+1.0)*0.55 + Math.sin(x*0.45+0.7)*Math.cos(z*0.4+2.2)*0.18;
const flat=THREE.MathUtils.smoothstep(r,3.5,10);
p.setY(i, h*flat);
const c = grass.clone().lerp(Math.random()<0.5?moss:dirt, Math.random()*0.7);
c.offsetHSL(0,0,(Math.random()-0.5)*0.04);
if(r<4.5) c.lerp(scorch, (1-r/4.5)*0.9);
colors.set([c.r,c.g,c.b], i*3);
}
groundGeo.setAttribute('color', new THREE.BufferAttribute(colors,3));
groundGeo.computeVertexNormals();
}
const ground = new THREE.Mesh(groundGeo, new THREE.MeshStandardMaterial({vertexColors:true, flatShading:true, roughness:1, metalness:0}));
ground.receiveShadow = true;
scene.add(ground);
```
Note smoothstep(r, 3.5, 10): MathUtils.smoothstep(x, min, max) — returns 0..1. Good.
Scorch disc:
```js
const scorch = new THREE.Mesh(new THREE.CircleGeometry(1.15, 24), new THREE.MeshBasicMaterial({color:0x0a0806, transparent:true, opacity:0.75, depthWrite:false}));
scorch.rotation.x=-Math.PI/2; scorch.position.y=0.02; scorch.renderOrder=1; scene.add(scorch);
```
Hmm depthWrite false with renderOrder above ground; ground at y=0 with displaced center flat=0 → y=0 near center. scorch at 0.02 fine. polygonOffset alternative; fine.
Fire group:
```js
const fire = new THREE.Group(); scene.add(fire);
// stone ring
const stoneGeo = new THREE.DodecahedronGeometry(1, 0);
const stoneMat = new THREE.MeshStandardMaterial({color:0x8a8d94, flatShading:true, roughness:0.9});
for(let i=0;i<9;i++){
const a=i/9*Math.PI*2 + Math.random()*0.4;
const r=0.62+Math.random()*0.12;
const s=0.14+Math.random()*0.16;
const st=new THREE.Mesh(stoneGeo, stoneMat);
st.position.set(Math.cos(a)*r, s*0.45, Math.sin(a)*r);
st.scale.set(s*(0.8+Math.random()*0.5), s*(0.5+Math.random()*0.4), s*(0.8+Math.random()*0.5));
st.rotation.set(Math.random()*3, Math.random()*3, Math.random()*3);
st.castShadow=st.receiveShadow=true;
fire.add(st);
}
// teepee logs
const barkMat = new THREE.MeshStandardMaterial({color:0x5a4028, flatShading:true, roughness:1});
for(let i=0;i<6;i++){
const a=i/6*Math.PI*2 + 0.26;
const base=new THREE.Vector3(Math.cos(a)*0.5, 0, Math.sin(a)*0.5);
const apex=new THREE.Vector3(0, 0.72, 0);
const dir=apex.clone().sub(base); const len=dir.length();
const log=new THREE.Mesh(new THREE.CylinderGeometry(0.07,0.095,len,6), barkMat);
log.position.copy(base).add(apex).multiplyScalar(0.5);
log.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0), dir.normalize());
log.castShadow=log.receiveShadow=true;
fire.add(log);
}
```
Wait apex y 0.72 with base r 0.5: len = sqrt(0.25+0.518)=~0.876. Logs lean nicely.
Flames:
```js
const flames=[];
const flameDefs=[ {r:0.36,h:1.15,c:0xff2e00,o:0.5}, {r:0.27,h:0.92,c:0xff7300,o:0.65}, {r:0.18,h:0.66,c:0xffb62e,o:0.8}, {r:0.10,h:0.45,c:0xfff0b8,o:0.95} ];
for(const d of flameDefs){
const g=new THREE.ConeGeometry(d.r, d.h, 7, 1);
g.translate(0, d.h/2, 0);
const m=new THREE.MeshBasicMaterial({color:d.c, transparent:true, opacity:d.o, blending:THREE.AdditiveBlending, depthWrite:false, side:THREE.DoubleSide});
const f=new THREE.Mesh(g,m);
f.position.y=0.22; f.rotation.y=Math.random()*6;
fire.add(f); flames.push(f);
}
// ember core
const ember=new THREE.Mesh(new THREE.IcosahedronGeometry(0.17,0), new THREE.MeshBasicMaterial({color:0xff6a00, blending:THREE.AdditiveBlending, transparent:true, opacity:0.9, depthWrite:false}));
ember.position.y=0.3; fire.add(ember);
// glow sprite
const fireGlow=new THREE.Sprite(new THREE.SpriteMaterial({map:makeRadialTexture('rgba(255,190,90,0.9)','rgba(255,120,40,0.35)','rgba(255,80,20,0)'), blending:THREE.AdditiveBlending, transparent:true, opacity:0.9, depthWrite:false}));
fireGlow.position.y=0.7; fireGlow.scale.setScalar(2.2); fire.add(fireGlow);
```
Trees:
```js
const trunkGeo=new THREE.CylinderGeometry(0.16,0.28,2.4,5);
const trunkMat=new THREE.MeshStandardMaterial({color:0x4a3423, flatShading:true, roughness:1});
const coneGeos=[new THREE.ConeGeometry(1.35,2.3,6), new THREE.ConeGeometry(1.0,2.0,6), new THREE.ConeGeometry(0.62,1.7,6)];
const folMats=[0x1e3d26,0x274a2c,0x183421].map(c=>new THREE.MeshStandardMaterial({color:c, flatShading:true, roughness:1}));
const TREE_COUNT=34;
for(let i=0;i<TREE_COUNT;i++){
let x,z,r;
do { const a=Math.random()*Math.PI*2; r=8.5+Math.random()*26; x=Math.cos(a)*r; z=Math.sin(a)*r; } while(Math.hypot(x,z)<7.5);
const t=new THREE.Group();
const trunk=new THREE.Mesh(trunkGeo, trunkMat);
trunk.position.y=1.2; trunk.castShadow=trunk.receiveShadow=true; t.add(trunk);
const fm=folMats[i%folMats.length];
const ys=[2.5,3.6,4.6];
coneGeos.forEach((g,j)=>{ const m=new THREE.Mesh(g,fm); m.position.y=ys[j]; m.castShadow=m.receiveShadow=true; t.add(m); });
const s=0.75+Math.random()*0.95;
t.scale.set(s,s*(0.9+Math.random()*0.3),s);
t.position.set(x,0,z);
t.rotation.y=Math.random()*Math.PI*2;
scene.add(t);
}
```
Trees cast shadow — 34 trees × 4 meshes into 1024 shadow map from point light (cube 6 renders!) Point light shadow = cube shadow map renders scene 6 times. 34 trees*4 + rocks etc ≈ 160 casters ×6 = ~1000 draw calls for shadow — might be heavy but okay on desktop; could reduce: shadow camera renders only near stuff? Point light shadow cube renders whole scene each face. 160 casters fine actually, each face ~160 draws → 960 draws + main 200 → ok at 60fps on typical GPU. Fine.
Alternatively reduce trees casting: all cast. OK.
Scattered rocks & fallen logs:
```js
for(let i=0;i<6;i++){
const a=Math.random()*Math.PI*2, r=3.2+Math.random()*6.5;
const s=0.25+Math.random()*0.5;
const rock=new THREE.Mesh(new THREE.DodecahedronGeometry(s,0), stoneMat);
rock.position.set(Math.cos(a)*r, s*0.4, Math.sin(a)*r);
rock.rotation.set(Math.random()*3,Math.random()*3,Math.random()*3);
rock.scale.y=0.6+Math.random()*0.4;
rock.castShadow=rock.receiveShadow=true;
scene.add(rock);
}
const logGeo=new THREE.CylinderGeometry(0.09,0.11,1.6,6);
for(let i=0;i<4;i++){
const a=Math.random()*Math.PI*2, r=2.2+Math.random()*2.2;
const log=new THREE.Mesh(logGeo, barkMat);
log.position.set(Math.cos(a)*r, 0.1, Math.sin(a)*r);
log.rotation.z=Math.PI/2; log.rotation.y=Math.random()*Math.PI*2;
// combine: set rotation order... simpler: log.rotation.set(Math.PI/2, 0, Math.random()*Math.PI*2)?
log.castShadow=log.receiveShadow=true;
scene.add(log);
}
```
Rotation: default order XYZ; rotation.z=PI/2 then rotation.y — Euler applied as R = Rx*Ry*Rz? Three.js Euler XYZ means rotation matrix Rz then Ry then Rx applied in order... Actually applies in order Z, then Y, then X? The matrix is composed as R = R_x * R_y * R_z? In three.js, Euler order 'XYZ' means the object is rotated around X, then Y, then Z in local... The composed matrix is Rz*Ry*Rx? Ugh. Simple: set rotation.set(0, Math.random()*PI*2, Math.PI/2, 'YXZ')? Just do: log.rotation.order='YXZ'; log.rotation.y=a; log.rotation.z=PI/2 — with order YXZ, Y applied first then X then Z? Matrix = RY * RX * RZ. With rotation.x=0, rotation.y=a, rotation.z=PI/2: R = Ry(a) * Rz(PI/2). Cylinder axis Y → Rz(PI/2) maps Y to X (horizontal), then Ry(a) rotates around world Y → horizontal log at angle a.
So: `log.rotation.order='YXZ'; log.rotation.y=Math.random()*Math.PI*2; log.rotation.z=Math.PI/2;` Good.
Sparks as planned.
Controls:
```js
const controls=new OrbitControls(camera, renderer.domElement);
controls.target.set(0,1.1,0);
controls.enableDamping=true; controls.dampingFactor=0.06;
controls.minDistance=4; controls.maxDistance=60;
controls.minPolarAngle=0.12;
controls.maxPolarAngle=Math.PI*0.495; // stay above ground
controls.autoRotate=true; controls.autoRotateSpeed=0.9;
let idleTimer=null;
controls.addEventListener('start',()=>{controls.autoRotate=false; if(idleTimer)clearTimeout(idleTimer);});
controls.addEventListener('end',()=>{ if(idleTimer)clearTimeout(idleTimer); idleTimer=setTimeout(()=>{controls.autoRotate=true;},6000); });
controls.addEventListener('change',()=>{
const t=controls.target;
t.y=THREE.MathUtils.clamp(t.y,0.4,8);
const hr=Math.hypot(t.x,t.z);
if(hr>12){ t.x*=12/hr; t.z*=12/hr; }
});
```
Resize:
```js
addEventListener('resize',()=>{
camera.aspect=innerWidth/innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth,innerHeight);
});
```
Animate:
```js
const clock=new THREE.Clock();
const sparkData=[];
function resetSpark(i, scattered){...}
init sparks...
function animate(){
requestAnimationFrame(animate);
const dt=Math.min(clock.getDelta(),0.05);
const t=clock.elapsedTime;
// flames
for(let i=0;i<flames.length;i++){
const f=flames[i];
const sway=Math.sin(t*(7+i*1.9)+i*2.4);
f.scale.set(1+0.16*sway+0.06*Math.sin(t*15+i), 1+0.32*Math.sin(t*(6+i*1.3)+i*1.7)+0.18*Math.sin(t*11.7+i*3), 1+0.16*Math.cos(t*(8.3+i)+i));
f.rotation.y=t*(1.2+i*0.35)+i*1.3;
f.position.x=Math.sin(t*2.6+i*2.1)*0.035;
f.position.z=Math.cos(t*2.2+i*1.4)*0.035;
f.material.opacity=(flameDefs[i].o)*(0.8+0.25*Math.sin(t*12+i*4.2));
}
ember.scale.setScalar(1+0.25*Math.sin(t*9.3));
// light flicker
const flick=Math.sin(t*11.3)*0.5+Math.sin(t*23.7+1.3)*0.3+Math.sin(t*5.1+4.0)*0.2;
fireLight.intensity=70*(1+0.22*flick)+Math.random()*5;
fireLight.position.y=0.95+0.06*Math.sin(t*8.8);
fireGlow.material.opacity=0.75+0.2*flick;
fireGlow.scale.setScalar(2.0+0.35*flick+0.1*Math.sin(t*13.7));
// sparks
for(let i=0;i<SPARKS;i++){
sLife[i]-=dt;
if(sLife[i]<=0) resetSpark(i,false);
const v=sVel[i];
v.y=Math.max(0.35, v.y-0.35*dt);
v.x+=Math.sin(t*4.1+i*1.3)*dt*0.5;
v.z+=Math.cos(t*3.3+i*2.1)*dt*0.5;
sPos[i*3]+=v.x*dt; sPos[i*3+1]+=v.y*dt; sPos[i*3+2]+=v.z*dt;
const f=Math.max(sLife[i]/sMax[i],0), b=f*f;
sCol[i*3]=b; sCol[i*3+1]=0.55*b*f; sCol[i*3+2]=0.12*b*f*f;
}
sparkGeo.attributes.position.needsUpdate=true;
sparkGeo.attributes.color.needsUpdate=true;
starMat.opacity=0.75+0.18*Math.sin(t*0.9)+0.07*Math.sin(t*2.3);
controls.update();
renderer.render(scene,camera);
}
animate();
```
Spark init: build arrays before animate; resetSpark uses sPos etc.
```js
const SPARKS=90;
const sPos=new Float32Array(SPARKS*3), sCol=new Float32Array(SPARKS*3);
const sVel=new Array(SPARKS), sLife=new Float32Array(SPARKS), sMax=new Float32Array(SPARKS);
function resetSpark(i, scattered){
const a=Math.random()*Math.PI*2, r=Math.random()*0.24;
sPos[i*3]=Math.cos(a)*r;
sPos[i*3+1]=scattered? 0.5+Math.random()*3.2 : 0.5+Math.random()*0.3;
sPos[i*3+2]=Math.sin(a)*r;
sVel[i]=new THREE.Vector3((Math.random()-0.5)*0.55, 0.9+Math.random()*1.5, (Math.random()-0.5)*0.55);
sMax[i]=0.8+Math.random()*1.7;
sLife[i]=scattered? Math.random()*sMax[i] : sMax[i];
}
for(let i=0;i<SPARKS;i++) resetSpark(i,true);
const sparkGeo=new THREE.BufferGeometry();
sparkGeo.setAttribute('position', new THREE.BufferAttribute(sPos,3));
sparkGeo.setAttribute('color', new THREE.BufferAttribute(sCol,3));
const sparks=new THREE.Points(sparkGeo, new THREE.PointsMaterial({size:0.13, vertexColors:true, transparent:true, opacity:1, blending:THREE.AdditiveBlending, depthWrite:false, sizeAttenuation:true}));
sparks.frustumCulled=false;
scene.add(sparks);
```
Note: resetSpark assigns sVel[i]=new Vector3 each reset — allocation per reset per frame for dead sparks; only a few per frame, fine.
Mobile 375px: HUD small, fine. Camera fov fine.
One more check: `THREE.MathUtils.smoothstep(r, 3.5, 10)` — signature smoothstep(x, min, max). Yes.
PointsMaterial fog:false — property exists on Material (fog boolean). Yes.
SpriteMaterial fog property exists too. Yes.
ShaderMaterial sky — fog doesn't apply since no fog chunks. Good.
CircleGeometry scorch with MeshBasicMaterial transparent — renders after ground due renderOrder 1. Ground renderOrder default 0. OK. Actually transparent objects render after opaque anyway; renderOrder within transparent sorted by depth. Fine, but depthWrite false and position y 0.02 above ground → visible. OK.
Check: fireLight distance 45, decay 2 — at ground r=8: intensity 70/64≈1.1, times albedo — visible warm pool. Good. Trees at 10-20 get 70/100..70/400 = 0.7..0.17 — dim rim, nice.
Hemisphere 0.55 + moon dir 0.5 gives night visibility. Exposure 1.15.
Shadow camera for point light: default cube camera near/far set. far=30 covers clearing. Trees beyond 30 from light not shadowed — fine.
One concern: point light shadow with 6 faces each rendering ~180 casters — ok.
Also set `renderer.domElement` style via CSS canvas display block; body overflow hidden prevents scrollbar.
autoRotate rotates camera around target — with maxPolarAngle limit, fine.
Double-check OrbitControls r170 API: maxPolarAngle etc. yes. 'start'/'end' events yes.
Also `controls.enablePan` default true; change-clamp handles target. Good.
Check flameDefs referenced in animate — define in outer scope. Yes.
Edge: `starMat.opacity` twinkle — fine.
Also ensure `innerWidth/innerHeight` used (window props). OK.
Potential issue: `Vector3.randomDirection()` exists r170? Added in r131? Let me recall — `randomDirection()` was added in r142? I believe it exists in recent versions (r170 yes, docs list it). Safe alternative: manual. I'll use manual to be safe:
```js
const v=new THREE.Vector3(Math.random()*2-1, Math.random()*2-1, Math.random()*2-1);
if(v.lengthSq()<0.001) v.set(0,1,0);
```
Then normalize. Safer.
Also `MathUtils.clamp` exists. Yes.
ConeGeometry(radius, height, radialSegments, heightSegments) — fine.
DodecahedronGeometry(radius, detail) fine.
Let me also add subtle "heat glow" light on ground? no.
Add a few distant tree silhouettes beyond fog? The 34 trees up to r=34.5; maybe add ring of extra far trees at r 40-55 darker to fill horizon: e.g., 20 more trees at r 38-55, they'll be heavily fogged silhouettes. Requirement ≥30; adding more fine. Let's include them (total 54). They'll be mostly fog — nice depth. Their shadows: point light far=30 won't include them; still rendered in main pass. OK.
Actually point light shadow camera far 30 — objects beyond don't cast in shadow map (clipped), fine.
Write final code now. Also ensure exactly one fenced code block, no text outside.
HUD font: Georgia serif fine.
Also add `touch-action: none` on canvas? OrbitControls sets it. Fine.
Let me write it fully and carefully check syntax.
One more: `scene.fog` affects PointsMaterial stars (fog:false set), moon (fog:false), sprites (fog:false), sky shader (no fog). Ground/trees fogged. Good.
Camera far 1500 > sky 700. Good.
Final check on import map exact string:
`<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>` — copy exactly.
Write it.