Models 44

MiniMax M3.1 Flash

by MiniMaxReasoningClean
Open in new window

Running live in a sandboxed frame. Click inside it before using the keyboard.

How it went

Total time
1 min 3 s
Cost
$0.0084
Output tokens
13,860
Spent thinking
6,203 tokens
Speed
220.6 tok/s
Console errors
0
First token after
392 ms
First visible text after
40.0 s
Prompt tokens
743
Code size
23.5 KB
Finish reason
stop
Served by
Parasail
Model id
minimax/minimax-m3.1-flash
Reasoning sent
{"effort":"high"}
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 · 608 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<title>Low-poly Campfire — Night Forest Clearing</title>
<style>
  :root { color-scheme: dark; }
  html, body { margin:0; padding:0; height:100%; width:100%; overflow:hidden; background:#04060c; }
  body { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
  #app { position:fixed; inset:0; }
  canvas { display:block; }
  #vignette {
    position:fixed; inset:0; pointer-events:none;
    background: radial-gradient(ellipse at 50% 58%, rgba(0,0,0,0) 35%, rgba(0,0,0,.55) 100%);
  }
  #hint {
    position:fixed; left:50%; bottom:18px; transform:translateX(-50%);
    color:#ffca7a; letter-spacing:.14em; text-transform:uppercase;
    font-size:11px; font-weight:600; opacity:.8; pointer-events:none;
    text-shadow:0 0 10px rgba(0,0,0,.95), 0 0 22px rgba(255,140,40,.35);
    transition: opacity 1.2s ease; white-space:nowrap;
  }
  #hint.fade { opacity:.28; }
  #title {
    position:fixed; top:16px; left:18px; color:#cfe0ff; pointer-events:none;
    font-size:12px; letter-spacing:.24em; text-transform:uppercase; opacity:.55;
    text-shadow:0 0 12px rgba(0,0,0,.9);
  }
  #title span { display:block; font-size:10px; letter-spacing:.16em; opacity:.6; margin-top:4px; color:#9fb4d8; }
</style>
</head>
<body>
<div id="app"></div>
<div id="vignette"></div>
<div id="title">Campfire<span>low-poly night clearing</span></div>
<div id="hint">Drag to look around · Scroll to zoom</div>

<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>

<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';

/* ------------------------------------------------------------------ */
/* deterministic-ish RNG                                                */
/* ------------------------------------------------------------------ */
let seed = 20240917;
function rand(){ seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; }
const rr = (a,b) => a + (b-a)*rand();
function smoothstep(a,b,x){ const t = Math.min(1, Math.max(0,(x-a)/(b-a))); return t*t*(3-2*t); }

/* ------------------------------------------------------------------ */
/* renderer / scene / camera                                            */
/* ------------------------------------------------------------------ */
const app = document.getElementById('app');
const renderer = new THREE.WebGLRenderer({ antialias:true, powerPreference:'high-performance' });
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.05;
app.appendChild(renderer.domElement);

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x060a14);
scene.fog = new THREE.FogExp2(0x070c17, 0.021);

const camera = new THREE.PerspectiveCamera(52, window.innerWidth/window.innerHeight, 0.1, 600);
camera.position.set(7.6, 3.4, 11.4);

const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1.35, 0);
controls.enableDamping = true;
controls.dampingFactor = 0.055;
controls.minDistance = 3.2;
controls.maxDistance = 55;
controls.minPolarAngle = 0.12;
controls.maxPolarAngle = Math.PI * 0.492;   // never dip below the ground
controls.enablePan = false;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.32;
controls.rotateSpeed = 0.75;
controls.zoomSpeed = 0.8;

/* ------------------------------------------------------------------ */
/* lights                                                              */
/* ------------------------------------------------------------------ */
const hemi = new THREE.HemisphereLight(0x2b3d68, 0x080b0c, 0.55);
scene.add(hemi);

const moonLight = new THREE.DirectionalLight(0x8fb0ff, 0.35);
moonLight.position.set(-28, 34, -46);
moonLight.castShadow = true;
moonLight.shadow.mapSize.set(1024,1024);
moonLight.shadow.camera.near = 1;
moonLight.shadow.camera.far = 160;
moonLight.shadow.camera.left = -32; moonLight.shadow.camera.right = 32;
moonLight.shadow.camera.top = 32;   moonLight.shadow.camera.bottom = -32;
moonLight.shadow.bias = -0.0012;
scene.add(moonLight);

const fireLight = new THREE.PointLight(0xff8a34, 30, 46, 2);
fireLight.position.set(0, 1.15, 0);
fireLight.castShadow = true;
fireLight.shadow.mapSize.set(1024,1024);
fireLight.shadow.camera.near = 0.25;
fireLight.shadow.camera.far = 40;
fireLight.shadow.bias = -0.004;
scene.add(fireLight);

/* ------------------------------------------------------------------ */
/* helpers for merged low-poly geometry                                */
/* ------------------------------------------------------------------ */
function colorize(geo, hex, jitter = 0){
  const c = new THREE.Color(hex);
  const pos = geo.attributes.position;
  const arr = new Float32Array(pos.count*3);
  for(let i=0;i<pos.count;i++){
    let f = 1;
    if(jitter > 0){
      const n = Math.sin(pos.getX(i)*12.9898 + pos.getY(i)*78.233 + pos.getZ(i)*37.719)*43758.5453;
      f = 1 + ((n - Math.floor(n)) - 0.5) * jitter;
    }
    arr[i*3] = c.r*f; arr[i*3+1] = c.g*f; arr[i*3+2] = c.b*f;
  }
  geo.setAttribute('color', new THREE.BufferAttribute(arr, 3));
  return geo;
}
function rockGeo(detail = 0, rough = 0.42){
  const g = new THREE.IcosahedronGeometry(1, detail);
  const p = g.attributes.position;
  for(let i=0;i<p.count;i++){
    const x=p.getX(i), y=p.getY(i), z=p.getZ(i);
    const n = Math.sin(x*12.9898 + y*78.233 + z*37.719)*43758.5453;
    const f = 1 + ((n - Math.floor(n)) - 0.5) * rough;
    p.setXYZ(i, x*f, y*f*0.82, z*f);
  }
  g.computeVertexNormals();
  return g;
}
function cylinderBetween(a, b, radius, seg = 7){
  const dir = new THREE.Vector3().subVectors(b, a);
  const len = dir.length();
  const g = new THREE.CylinderGeometry(radius, radius*0.92, len, seg, 1);
  const m = new THREE.Matrix4();
  const q = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0,1,0), dir.clone().normalize());
  m.compose(new THREE.Vector3().addVectors(a,b).multiplyScalar(0.5), q, new THREE.Vector3(1,1,1));
  g.applyMatrix4(m);
  return g;
}

/* ------------------------------------------------------------------ */
/* terrain                                                             */
/* ------------------------------------------------------------------ */
function terrainH(x, z){
  const d = Math.hypot(x, z);
  const n = Math.sin(x*0.085)*1.25 + Math.cos(z*0.105)*0.95 + Math.sin((x*0.62 + z*0.44)*0.048)*2.4;
  return n * smoothstep(3.5, 15, d) * 0.85;
}

const GROUND_SIZE = 190, GSEG = 64;
const groundGeo = new THREE.PlaneGeometry(GROUND_SIZE, GROUND_SIZE, GSEG, GSEG);
groundGeo.rotateX(-Math.PI/2);
{
  const p = groundGeo.attributes.position;
  const col = new Float32Array(p.count*3);
  const cGrass = new THREE.Color(0x1d3025);
  const cDirt  = new THREE.Color(0x3c3026);
  const cMoss  = new THREE.Color(0x2b4430);
  const tmp = new THREE.Color();
  for(let i=0;i<p.count;i++){
    const x = p.getX(i), z = p.getZ(i);
    const y = terrainH(x, z);
    p.setY(i, y);
    const d = Math.hypot(x, z);
    tmp.copy(cDirt).lerp(cGrass, smoothstep(2.0, 11.0, d));
    tmp.lerp(cMoss, 0.35 * (0.5 + 0.5*Math.sin(x*0.7 + z*0.5)));
    const v = 0.82 + 0.28*Math.sin(x*1.7 + z*1.3);
    col[i*3] = tmp.r*v; col[i*3+1] = tmp.g*v; col[i*3+2] = tmp.b*v;
  }
  groundGeo.setAttribute('color', new THREE.BufferAttribute(col, 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);

/* ------------------------------------------------------------------ */
/* trees (>= 30)                                                        */
/* ------------------------------------------------------------------ */
const GREENS = [0x1b3a27, 0x24482e, 0x2c5735, 0x15301f, 0x35633c];

function makeTreeGeo(){
  const parts = [];
  const trunkH = rr(1.1, 1.9);
  const trunk = new THREE.CylinderGeometry(0.13, 0.24, trunkH, 6, 1);
  trunk.translate(0, trunkH*0.5, 0);
  colorize(trunk, 0x3b2b1e, 0.18);
  parts.push(trunk);
  let y = trunkH*0.72, r = rr(1.35,1.9), h = rr(2.0,2.8);
  const layers = 3;
  for(let i=0;i<layers;i++){
    const cone = new THREE.ConeGeometry(r, h, 7, 1);
    cone.translate(0, y + h*0.5, 0);
    colorize(cone, GREENS[(Math.random()*GREENS.length)|0], 0.22);
    parts.push(cone);
    y += h*0.52; r *= 0.72; h *= 0.86;
  }
  return mergeGeometries(parts, false);
}
const treeGeos = [makeTreeGeo(), makeTreeGeo(), makeTreeGeo(), makeTreeGeo(), makeTreeGeo()];
const treeMat = new THREE.MeshStandardMaterial({ vertexColors:true, flatShading:true, roughness:0.95, metalness:0 });
const treeGroup = new THREE.Group();
scene.add(treeGroup);

const TREE_COUNT = 48;
let placed = 0, guard = 0;
while(placed < TREE_COUNT && guard < 4000){
  guard++;
  const a = rand()*Math.PI*2;
  const rad = 8.5 + Math.pow(rand(), 0.55)*34;      // clearing stays open
  const x = Math.cos(a)*rad, z = Math.sin(a)*rad;
  if(Math.hypot(x,z) < 8.2) continue;
  const m = new THREE.Mesh(treeGeos[(rand()*treeGeos.length)|0], treeMat);
  const s = rr(0.85, 1.75);
  m.scale.set(s, s*rr(0.9,1.45), s);
  m.position.set(x, terrainH(x,z) - 0.15, z);
  m.rotation.y = rand()*Math.PI*2;
  m.castShadow = true; m.receiveShadow = true;
  treeGroup.add(m);
  placed++;
}
// far silhouette ring, faded by fog
{
  const parts = [];
  for(let i=0;i<46;i++){
    const a = rand()*Math.PI*2, rad = rr(48, 86);
    const x = Math.cos(a)*rad, z = Math.sin(a)*rad;
    const g = treeGeos[(rand()*treeGeos.length)|0].clone();
    const s = rr(1.1, 2.2);
    g.applyMatrix4(new THREE.Matrix4().compose(
      new THREE.Vector3(x, terrainH(x,z)-0.4, z),
      new THREE.Quaternion().setFromEuler(new THREE.Euler(0, rand()*6.28, 0)),
      new THREE.Vector3(s, s*1.15, s)
    ));
    parts.push(g);
  }
  const far = new THREE.Mesh(mergeGeometries(parts, false), treeMat);
  far.castShadow = false; far.receiveShadow = false;
  scene.add(far);
}

/* ------------------------------------------------------------------ */
/* rocks + logs                                                         */
/* ------------------------------------------------------------------ */
{
  const parts = [];
  // scattered rocks
  for(let i=0;i<26;i++){
    const a = rand()*Math.PI*2, rad = rr(3.0, 16);
    const x = Math.cos(a)*rad, z = Math.sin(a)*rad;
    const g = rockGeo(0, 0.45);
    const s = rr(0.35, 1.5);
    g.applyMatrix4(new THREE.Matrix4().compose(
      new THREE.Vector3(x, terrainH(x,z) + s*0.35, z),
      new THREE.Quaternion().setFromEuler(new THREE.Euler(rand()*0.5, rand()*6.28, rand()*0.5)),
      new THREE.Vector3(s, s*rr(0.6,1.0), s*rr(0.8,1.2))
    ));
    colorize(g, 0x5c5f66, 0.3);
    parts.push(g);
  }
  // fire ring stones
  for(let i=0;i<11;i++){
    const a = (i/11)*Math.PI*2 + rr(-0.1,0.1);
    const rad = rr(0.92, 1.18);
    const x = Math.cos(a)*rad, z = Math.sin(a)*rad;
    const g = rockGeo(0, 0.5);
    const s = rr(0.26, 0.42);
    g.applyMatrix4(new THREE.Matrix4().compose(
      new THREE.Vector3(x, 0.02 + s*0.3, z),
      new THREE.Quaternion().setFromEuler(new THREE.Euler(rand()*0.6, rand()*6.28, rand()*0.6)),
      new THREE.Vector3(s*1.25, s, s*1.1)
    ));
    colorize(g, 0x6a6b70, 0.3);
    parts.push(g);
  }
  const rockMesh = new THREE.Mesh(mergeGeometries(parts, false),
    new THREE.MeshStandardMaterial({ vertexColors:true, flatShading:true, roughness:0.92, metalness:0.02 }));
  rockMesh.castShadow = true; rockMesh.receiveShadow = true;
  scene.add(rockMesh);
}
{
  const parts = [];
  // teepee logs over the fire
  for(let i=0;i<6;i++){
    const a = (i/6)*Math.PI*2 + rr(-0.2,0.2);
    const base = new THREE.Vector3(Math.cos(a)*1.05, 0.06, Math.sin(a)*1.05);
    const tip  = new THREE.Vector3(Math.cos(a+Math.PI)*0.18, rr(1.25,1.6), Math.sin(a+Math.PI)*0.18);
    const g = cylinderBetween(base, tip, rr(0.11,0.16), 7);
    colorize(g, 0x4a3423, 0.2);
    parts.push(g);
  }
  // fallen logs nearby
  for(let i=0;i<6;i++){
    const a = rand()*Math.PI*2, rad = rr(4.2, 8.5);
    const x = Math.cos(a)*rad, z = Math.sin(a)*rad;
    const len = rr(2.2, 4.0);
    const dir = rr(-0.7,0.7) + a;
    const p0 = new THREE.Vector3(x, terrainH(x,z)+0.18, z);
    const p1 = new THREE.Vector3(x + Math.cos(dir)*len, terrainH(x,z)+0.18, z + Math.sin(dir)*len);
    const g = cylinderBetween(p0, p1, rr(0.22,0.34), 8);
    colorize(g, 0x43301f, 0.22);
    parts.push(g);
  }
  const logMesh = new THREE.Mesh(mergeGeometries(parts, false),
    new THREE.MeshStandardMaterial({ vertexColors:true, flatShading:true, roughness:0.95, metalness:0 }));
  logMesh.castShadow = true; logMesh.receiveShadow = true;
  scene.add(logMesh);
}

/* ------------------------------------------------------------------ */
/* grass tufts                                                          */
/* ------------------------------------------------------------------ */
{
  const parts = [];
  for(let i=0;i<110;i++){
    const a = rand()*Math.PI*2, rad = rr(2.4, 17);
    const x = Math.cos(a)*rad, z = Math.sin(a)*rad;
    const g = new THREE.ConeGeometry(0.055, rr(0.35,0.75), 4, 1);
    g.translate(0, 0.3, 0);
    g.applyMatrix4(new THREE.Matrix4().compose(
      new THREE.Vector3(x, terrainH(x,z), z),
      new THREE.Quaternion().setFromEuler(new THREE.Euler(rr(-0.25,0.25), rand()*6.28, rr(-0.25,0.25))),
      new THREE.Vector3(1,1,1)
    ));
    colorize(g, 0x2f5a35, 0.3);
    parts.push(g);
  }
  const grass = new THREE.Mesh(mergeGeometries(parts, false),
    new THREE.MeshStandardMaterial({ vertexColors:true, flatShading:true, roughness:1 }));
  grass.castShadow = false; grass.receiveShadow = true;
  scene.add(grass);
}

/* ------------------------------------------------------------------ */
/* sky: stars + moon                                                    */
/* ------------------------------------------------------------------ */
{
  const N = 1400;
  const pos = new Float32Array(N*3), col = new Float32Array(N*3);
  const c = new THREE.Color();
  for(let i=0;i<N;i++){
    const u = rand()*2-1, th = rand()*Math.PI*2;
    const r = Math.sqrt(1-u*u);
    const R = 280;
    pos[i*3]   = Math.cos(th)*r*R;
    pos[i*3+1] = Math.abs(u)*R*0.85 + 12;
    pos[i*3+2] = Math.sin(th)*r*R;
    const w = 0.55 + 0.45*rand();
    c.setHSL(0.55 + rand()*0.08, 0.25*rand(), 0.55 + 0.4*rand());
    col[i*3] = c.r*w; col[i*3+1] = c.g*w; col[i*3+2] = c.b*w;
  }
  const g = new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.BufferAttribute(pos,3));
  g.setAttribute('color', new THREE.BufferAttribute(col,3));
  const stars = new THREE.Points(g, new THREE.PointsMaterial({
    size:1.9, sizeAttenuation:false, vertexColors:true, transparent:true,
    depthWrite:false, fog:false
  }));
  scene.add(stars);
}
{
  const moon = new THREE.Mesh(
    new THREE.IcosahedronGeometry(7.5, 2),
    new THREE.MeshBasicMaterial({ color:0xeaf0ff, fog:false })
  );
  moon.position.set(-72, 52, -120);
  scene.add(moon);

  // soft halo
  const cv = document.createElement('canvas'); cv.width = cv.height = 128;
  const ctx = cv.getContext('2d');
  const grd = ctx.createRadialGradient(64,64,0,64,64,64);
  grd.addColorStop(0,'rgba(200,220,255,0.9)');
  grd.addColorStop(0.28,'rgba(160,190,255,0.28)');
  grd.addColorStop(1,'rgba(120,160,255,0)');
  ctx.fillStyle = grd; ctx.fillRect(0,0,128,128);
  const tex = new THREE.CanvasTexture(cv);
  const halo = new THREE.Sprite(new THREE.SpriteMaterial({ map:tex, transparent:true, depthWrite:false, fog:false, blending:THREE.AdditiveBlending, opacity:0.85 }));
  halo.position.copy(moon.position);
  halo.scale.setScalar(46);
  scene.add(halo);
}

/* ------------------------------------------------------------------ */
/* fire                                                                */
/* ------------------------------------------------------------------ */
const fire = new THREE.Group();
scene.add(fire);

// glowing coal bed
{
  const parts = [];
  for(let i=0;i<14;i++){
    const a = rand()*Math.PI*2, rad = rand()*0.6;
    const g = rockGeo(0, 0.5);
    const s = rr(0.08, 0.19);
    g.applyMatrix4(new THREE.Matrix4().compose(
      new THREE.Vector3(Math.cos(a)*rad, 0.03 + s*0.4, Math.sin(a)*rad),
      new THREE.Quaternion().setFromEuler(new THREE.Euler(rand()*3, rand()*6.28, rand()*3)),
      new THREE.Vector3(s, s*0.7, s)
    ));
    colorize(g, 0x3a1408);
    parts.push(g);
  }
  const coals = new THREE.Mesh(mergeGeometries(parts,false),
    new THREE.MeshStandardMaterial({ vertexColors:true, flatShading:true, roughness:0.85,
      emissive:0xff3c00, emissiveIntensity:1.6 }));
  fire.add(coals);
  fire.userData.coals = coals;
}

// flame cones
const flames = [];
function addFlame(radius, height, color, opacity, x, z, seg){
  const g = new THREE.ConeGeometry(radius, height, seg||6, 1);
  g.translate(0, height*0.5, 0);
  const m = new THREE.Mesh(g, new THREE.MeshBasicMaterial({
    color, transparent:true, opacity, depthWrite:false,
    blending:THREE.AdditiveBlending, side:THREE.DoubleSide
  }));
  m.position.set(x, 0.08, z);
  fire.add(m);
  flames.push({ mesh:m, base:{x,z}, phase: Math.random()*6.28, h:height, r:radius, op:opacity, speed: rr(4.5,7.5) });
}
for(let i=0;i<6;i++){
  const a = (i/6)*Math.PI*2 + rr(-0.2,0.2);
  addFlame(rr(0.24,0.36), rr(1.15,1.75), 0xff5a0a, 0.62, Math.cos(a)*0.34, Math.sin(a)*0.34);
}
for(let i=0;i<4;i++){
  const a = (i/4)*Math.PI*2 + rr(-0.3,0.3);
  addFlame(rr(0.13,0.2), rr(0.8,1.15), 0xffb43a, 0.72, Math.cos(a)*0.16, Math.sin(a)*0.16);
}
addFlame(0.22, 0.85, 0xfff0b0, 0.85, 0, 0);

// central glow blob
const glowTex = (() => {
  const cv = document.createElement('canvas'); cv.width = cv.height = 128;
  const ctx = cv.getContext('2d');
  const g = ctx.createRadialGradient(64,64,0,64,64,64);
  g.addColorStop(0,'rgba(255,210,120,0.95)');
  g.addColorStop(0.3,'rgba(255,120,30,0.35)');
  g.addColorStop(1,'rgba(255,80,0,0)');
  ctx.fillStyle = g; ctx.fillRect(0,0,128,128);
  return new THREE.CanvasTexture(cv);
})();
const glow = new THREE.Sprite(new THREE.SpriteMaterial({
  map:glowTex, transparent:true, depthWrite:false, blending:THREE.AdditiveBlending, opacity:0.85
}));
glow.position.set(0, 0.75, 0);
glow.scale.setScalar(5.5);
fire.add(glow);
const glow2 = new THREE.Sprite(glow.material.clone());
glow2.material.opacity = 0.4;
glow2.position.set(0, 0.5, 0);
glow2.scale.setScalar(9);
fire.add(glow2);

/* ------------------------------------------------------------------ */
/* sparks (particle system)                                             */
/* ------------------------------------------------------------------ */
const SPARK_N = 170;
const sparkPos  = new Float32Array(SPARK_N*3);
const sparkCol  = new Float32Array(SPARK_N*3);
const sparkVel  = new Float32Array(SPARK_N*3);
const sparkLife = new Float32Array(SPARK_N);
const sparkMax  = new Float32Array(SPARK_N);
const sparkSeed = new Float32Array(SPARK_N);

function respawnSpark(i, initial){
  const a = Math.random()*Math.PI*2, r = Math.random()*0.45;
  sparkPos[i*3]   = Math.cos(a)*r;
  sparkPos[i*3+1] = initial ? Math.random()*3.5 : 0.25 + Math.random()*0.4;
  sparkPos[i*3+2] = Math.sin(a)*r;
  sparkVel[i*3]   = (Math.random()-0.5)*0.55;
  sparkVel[i*3+1] = rr(1.1, 2.8);
  sparkVel[i*3+2] = (Math.random()-0.5)*0.55;
  sparkLife[i] = 0;
  sparkMax[i]  = rr(1.1, 2.6);
  sparkSeed[i] = Math.random()*100;
}
for(let i=0;i<SPARK_N;i++) respawnSpark(i, true);

const sparkGeo = new THREE.BufferGeometry();
sparkGeo.setAttribute('position', new THREE.BufferAttribute(sparkPos,3));
sparkGeo.setAttribute('color', new THREE.BufferAttribute(sparkCol,3));
const sparkTex = (() => {
  const cv = document.createElement('canvas'); cv.width = cv.height = 64;
  const ctx = cv.getContext('2d');
  const g = ctx.createRadialGradient(32,32,0,32,32,32);
  g.addColorStop(0,'rgba(255,255,255,1)');
  g.addColorStop(0.25,'rgba(255,200,110,0.9)');
  g.addColorStop(0.6,'rgba(255,110,30,0.35)');
  g.addColorStop(1,'rgba(255,80,0,0)');
  ctx.fillStyle = g; ctx.fillRect(0,0,64,64);
  return new THREE.CanvasTexture(cv);
})();
const sparks = new THREE.Points(sparkGeo, new THREE.PointsMaterial({
  size:0.24, map:sparkTex, transparent:true, vertexColors:true,
  depthWrite:false, blending:THREE.AdditiveBlending, sizeAttenuation:true
}));
scene.add(sparks);

/* ------------------------------------------------------------------ */
/* animation                                                            */
/* ------------------------------------------------------------------ */
const clock = new THREE.Clock();
let t = 0;

function animate(){
  requestAnimationFrame(animate);
  const dt = Math.min(clock.getDelta(), 0.05);
  t += dt;

  /* flames */
  for(let i=0;i<flames.length;i++){
    const f = flames[i];
    const p = f.phase;
    const wob = Math.sin(t*f.speed + p);
    const wob2 = Math.sin(t*f.speed*0.63 + p*1.7);
    f.mesh.scale.set(
      (0.85 + 0.28*wob2) * (1 + 0.05*wob),
      (0.75 + 0.42*(wob*0.5+0.5)) * (0.9 + 0.18*wob2),
      (0.85 + 0.28*wob2)
    );
    f.mesh.rotation.y = t*0.9 + p;
    f.mesh.position.x = f.base.x + 0.07*Math.sin(t*3.1 + p);
    f.mesh.position.z = f.base.z + 0.07*Math.cos(t*2.7 + p);
    f.mesh.position.y = 0.08 + 0.05*wob2;
    f.mesh.material.opacity = Math.max(0, Math.min(1, f.op * (0.78 + 0.3*wob2)));
  }

  /* fire light flicker */
  const flick = 0.78
    + 0.16*Math.sin(t*11.3)
    + 0.10*Math.sin(t*23.7 + 1.3)
    + 0.09*Math.sin(t*37.1 + 2.6)
    + 0.06*Math.sin(t*7.9);
  fireLight.intensity = 22 + 16*flick;
  fireLight.position.y = 1.05 + 0.12*Math.sin(t*9.0);
  fire.userData.coals.material.emissiveIntensity = 1.1 + 1.2*flick;
  glow.material.opacity = 0.6 + 0.35*flick;
  glow.scale.setScalar(4.6 + 1.5*flick);
  glow2.material.opacity = 0.28 + 0.2*flick;

  /* sparks */
  for(let i=0;i<SPARK_N;i++){
    sparkLife[i] += dt;
    if(sparkLife[i] > sparkMax[i]) { respawnSpark(i, false); continue; }
    const k = sparkLife[i] / sparkMax[i];
    const drift = Math.sin(t*2.4 + sparkSeed[i]) * 0.4;
    sparkVel[i*3]   += (drift - sparkVel[i*3]*0.9) * dt;
    sparkVel[i*3+2] += (Math.cos(t*2.1 + sparkSeed[i])*0.4 - sparkVel[i*3+2]*0.9) * dt;
    sparkVel[i*3+1] *= (1 - 0.35*dt);
    sparkPos[i*3]   += sparkVel[i*3]*dt;
    sparkPos[i*3+1] += sparkVel[i*3+1]*dt;
    sparkPos[i*3+2] += sparkVel[i*3+2]*dt;
    const fade = Math.pow(1-k, 1.4);
    const flick = 0.75 + 0.25*Math.sin(t*20 + sparkSeed[i]*3);
    sparkCol[i*3]   = 1.0*fade*flick;
    sparkCol[i*3+1] = (0.55 - 0.35*k)*fade*flick;
    sparkCol[i*3+2] = (0.18 - 0.16*k)*fade*flick;
  }
  sparkGeo.attributes.position.needsUpdate = true;
  sparkGeo.attributes.color.needsUpdate = true;

  controls.update();
  renderer.render(scene, camera);
}
animate();

/* ------------------------------------------------------------------ */
/* resize                                                              */
/* ------------------------------------------------------------------ */
function onResize(){
  const w = window.innerWidth, h = window.innerHeight;
  camera.aspect = w/h;
  camera.updateProjectionMatrix();
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  renderer.setSize(w, h);
}
window.addEventListener('resize', onResize);
onResize();

/* gentle hint fade */
const hint = document.getElementById('hint');
setTimeout(() => hint.classList.add('fade'), 5500);
renderer.domElement.addEventListener('pointerdown', () => hint.classList.add('fade'), { once:true });
</script>
</body>
</html>
Reasoning trace (6,203 tokens)
Let me build a single HTML file with Three.js via import map, using low-poly aesthetic: flat shading, cone trees, rocks (icosahedron distorted), logs (cylinders), campfire with animated flame cones, point light flickering with shadows, sparks as points, star field, moon, fog, OrbitControls with polar angle limits.

Let me write careful code.

Key elements:
- Scene with fog (FogExp2 dark blue).
- Renderer: antialias, shadowMap enabled PCFSoft.
- Camera perspective, OrbitControls with maxPolarAngle ~ Math.PI/2 - 0.05, minPolarAngle 0.2, target at fire, distance limits.
- Ground: PlaneGeometry with vertices displaced, low-poly flat shading, MeshLambertMaterial or Standard with flatShading.
- Trees: cone stacks (2-3 cones) + trunk cylinder, flat shading, random rotation/scale. 30+ placed in a ring, avoiding fire radius.
- Rocks: icosahedron with random vertex jitter, flat shaded.
- Logs: cylinders rotated, around fire.
- Fire: stones ring, logs teepee, flames: several cone meshes with additive blending, animated scale/rotation/opacity; point light flickering.
- Sparks: Points with custom buffer positions updated each frame, or small tetrahedra. Use Points with PointsMaterial size attenuation, additive blending.
- Stars: Points spread on a sphere, or small tetrahedra. Use Points in a large sphere.
- Moon: sphere + maybe glow sprite.
- Fire glow on ground.

Let me also add a subtle "Click or press a key to start" message? That's required for games; this is a scene, not a game. But OrbitControls needs interaction — mouse works without keyboard. The requirement says "if the demo uses the keyboard". We don't need keyboard. But to be safe, add a hint overlay "Drag to orbit · Scroll to zoom" which fades. Actually screenshot is taken 3s after load without interaction — an overlay saying "Click to start" might obscure. I'll show a small hint that fades out after a few seconds, but the scene should be visible. Let's make the hint at the bottom, subtle, fading to low opacity after 4s.

Actually, maybe I should include a "click to start" overlay that dims? Better: keep a small unobtrusive hint at bottom-left that fades out. Fine.

Let me handle the flame animation: build flames as a set of cone geometries with random phases. Each frame, update scale.y, scale.x, position slightly, and material opacity based on life.

Use a simple approach: flames are merged-ish group of cones with MeshBasicMaterial with color gradient (orange/yellow), additive blending, depthWrite false.

Fire light: PointLight(0xff8833, intensity, distance, decay). Flicker using noise: intensity = base * (0.75 + 0.35*sin(t*11) + random). Shadows: castShadow true, mapSize 1024.

Also add a subtle blue-ish moonlight directional light with shadows for trees.

Ground displacement: PlaneGeometry(120,120,40,40) rotated -PI/2, displace y with noise; flat shading; material MeshStandardMaterial color dark green/brown, roughness 1, flatShading true. Vertex colors for variation maybe.

Fog: FogExp2(0x0a0e1a, 0.02). Trees at distance fade.

Stars: Points with 1200 stars on sphere radius 300, but fog would affect them — set material fog: false.

Moon: a circle/sphere at position, MeshBasicMaterial white, fog false. Maybe use a sprite glow.

Sparks: BufferGeometry with N=120 points, positions updated in JS each frame: rise with velocity, gravity-ish, fade. Use PointsMaterial with map? Use a canvas-generated circular texture for soft round sprite. Size 0.15, sizeAttenuation true, color orange, transparent, blending additive, depthWrite false.

Also embers glowing.

Let's write code.

Trees: make a function createTree(rng). Trunk: CylinderGeometry(0.18,0.28,1.6,6) flat shaded brown. Foliage: 3 ConeGeometry(r,h,7) stacked with decreasing radius, flat shaded dark green (vary hue). Height total ~4-7. Randomize colors among a few greens.

Place trees: random positions in annulus radius 6..55, excluding fire area radius 3.5. Also cluster clearing: the clearing radius ~8 with no trees.

Rocks: IcosahedronGeometry(1,0) with jitter, scale random 0.4-1.6, placed near fire (radius 2-7) and scattered.

Logs: cylinders length 2-3 radius 0.25, lying at angles around fire, some crossing.

Fire: ring of stones (8 rocks) around center, logs arranged in teepee crossing.

Camera: position (0, 4, 12), target (0, 1.2, 0).

OrbitControls: enableDamping true, dampingFactor 0.06, maxPolarAngle Math.PI*0.495 (so camera stays above ground relative to target), minDistance 4, maxDistance 40, enablePan false maybe (limits). maxPolarAngle = Math.PI/2 - 0.02 works since target is above ground.

Also auto-rotate slowly? Could add controls.autoRotate = true with slow speed; but user interaction disables? autoRotate continues. Maybe enable autoRotate false but add slight camera sway... I'll enable autoRotate = true at speed 0.3 so scene shows motion in screenshot? Not needed. I'll add autoRotate true — it's nice for a demo. Actually it might be nice; user can still interact. Let's enable autoRotate = true, autoRotateSpeed = 0.4.

Hmm, but autoRotate + damping fine.

Let me write the flame animation more concretely.

Flames: create 5 cone meshes with radius 0.28, height 1.6, segments 6, colored with gradient: MeshBasicMaterial color 0xff7a1a, transparent, opacity 0.85, blending AdditiveBlending, depthWrite false, flatShading true (Basic doesn't need flatShading; cone geometry with 6 segments already looks low-poly).

Each flame i: phase = i/5*PI*2, base position offset small. Update:
- scale.y = 0.8 + 0.45*(sin(t*6 + phase)*0.5+0.5)
- scale.x = scale.z = 0.85 + 0.3*sin(t*7.3+phase*2)
- rotation.y = t*0.6 + phase
- position.y = base + 0.1*sin(t*5+phase)
- material.opacity = 0.6+0.3*sin(...)

Also a small inner bright cone (yellow-white) at center, and a glow sphere.

Add ember glow light on ground.

Also fire "coal" bed: small icosahedron with emissive orange material.

Let me write the code now, keeping it robust.

Canvas texture for sparks: create 32x32 radial gradient canvas.

Also shadow: point light shadows are expensive with 6 faces; use mapSize 512 and only render... fine, 6 shadow passes of small scene. Might be okay. Alternatively use a SpotLight? Requirement: warm point light that flickers and casts shadows. Use PointLight castShadow with shadow.mapSize 1024, and set shadow.camera.near 0.1 far 30, bias -0.005. Trees cast shadows. Should be fine perf-wise with 30 low-poly trees.

Also add moon directional light castShadow for tree shadows onto ground — optional but nice; use low intensity blue. Let's include with mapSize 1024 and tight ortho frustum around clearing (radius 40).

Careful about total shadow render cost: point light 6 faces + directional. Fine.

Let me write the HTML.

Code:

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Campfire — Low-poly Night Forest</title>
<style>
  html,body{margin:0;height:100%;overflow:hidden;background:#05070d;}
  #hint{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);color:#ffd9a0;font:13px/1.4 system-ui, sans-serif;letter-spacing:.08em;text-transform:uppercase;opacity:.85;pointer-events:none;text-shadow:0 0 12px rgba(0,0,0,.9);transition:opacity 1s; }
  ...
</style>
</head>
<body>
<div id="hint">Drag to look around · Scroll to zoom</div>
<script type="importmap">...</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
...
</script>
</body>
</html>
```

Now write scene details.

Random: use Math.random seeded? Use a simple LCG for deterministic-ish layout.

Let me write it.

Ground: PlaneGeometry(140,140,48,48), rotateX(-PI/2). Displace vertices: y = noise via sin combos; flat shading with vertex colors based on height & radius. Actually let me use a value-noise function simple: combination of sin/cos.

function groundHeight(x,z){ return Math.sin(x*0.12)*0.9 + Math.cos(z*0.15)*0.7 + Math.sin((x+z)*0.07)*1.1; } and then flatten near center: factor = clamp(dist/6,0,1) smoothstep, so center flat-ish.

Vertex colors: darker green farther, brownish near center (ash). Use Color lerp.

Material: MeshStandardMaterial({vertexColors:true, flatShading:true, roughness:1, metalness:0}).

Trees: keep count 46.

Tree placement: for i<46: angle random, radius = 9 + rand^0.6 * 42 (so some closer). Ensure not in front of camera blocking? Fine, trees around.

Actually clearing radius 9 with no trees; camera starts at z=13 which is inside the ring — ok, trees behind camera too.

Also add some distant tree silhouettes ring at radius 55-70 for depth (fog will fade them).

Rocks: 18 rocks scattered radius 3..14. Plus 9 stones in fire ring.

Logs: 6 logs around fire radius 2.5-4.

Fire center at (0,0,0). Ground height at center should be ~0 → define height function relative to center at 0.

Let me define terrain as function h(x,z) used for placing objects (trees, rocks) so they sit on ground.

h(x,z) = blend of sines * flattening.

I'll compute ground vertex y = h(x,z) directly, and place objects at y = h(x,z).

Let's define:
```
function terrainH(x,z){
  const d = Math.hypot(x,z);
  const n = Math.sin(x*0.09)*1.2 + Math.cos(z*0.11)*1.0 + Math.sin((x*0.6+z*0.4)*0.05)*2.2;
  const flat = smoothstep(4, 14, d);
  return n*flat*0.8;
}
```
smoothstep(a,b,x) = t*t*(3-2t).

Center h(0,0)=0. Good.

Stars: generate 1500 points on sphere radius 300, upper hemisphere mostly. PointsMaterial size 1.2, sizeAttenuation false? With sizeAttenuation false, size in pixels. Use size 1.6, sizeAttenuation:false, color varied via vertex colors, fog:false, transparent with alphaTest? PointsMaterial doesn't support per-point alpha but vertexColors ok. Use color attribute.

Moon: Mesh SphereGeometry(6, 16, 12) MeshBasicMaterial color 0xf2f6ff fog:false at position (-70, 45, -110). Add a glow: Sprite with radial texture, scale 40, additive.

Also a moon disc low-poly: use IcosahedronGeometry(6,1)? Sphere fine.

Sparks particle system:
- N = 140.
- each: pos (x,z random within 0.5 radius, y ~0.6), vel upward 1.5-3, life.
- update: pos += vel*dt; vel.y -= 0.6*dt? Actually sparks rise then slow; use vel.y *= 0.99 and add slight buoyancy. Fade: alpha via color attribute? PointsMaterial doesn't support per-vertex alpha, but with vertexColors and additive blending, scaling color toward black fades it. Use color attribute updated = baseColor * alpha. Works with additive blending.
- respawn when y > 6.

Implement custom ShaderMaterial? Simpler: PointsMaterial with vertexColors true, blending AdditiveBlending, map = sparkTexture, transparent true, depthWrite false, size 0.22, sizeAttenuation true.

Good.

Also floating embers with slight horizontal drift.

Add a warm "fire glow" plane? The point light handles it.

Additional: subtle volumetric-ish light shafts? Skip.

Let me also add a small vignette overlay via CSS radial gradient for polish.

Now controls: 
```
controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0,1.4,0);
controls.enableDamping = true; controls.dampingFactor=0.05;
controls.minDistance=4; controls.maxDistance=60;
controls.maxPolarAngle=Math.PI*0.49; controls.minPolarAngle=0.15;
controls.enablePan=false;
controls.autoRotate=true; controls.autoRotateSpeed=0.25;
```
autoRotate requires controls.update() each frame with damping enabled — works (autoRotate applied in update).

But autoRotate may conflict with "limits so camera stays above ground" — no.

Hmm, autoRotate might annoy; but shows motion. Keep it, small speed.

Resize handler: update camera aspect, renderer size.

Pixel ratio: min(devicePixelRatio, 2).

Render loop with clock delta clamped.

Let me write everything carefully now.

Flame geometry low-poly cones: ConeGeometry(0.3, 1.5, 6, 1, true) open ended? keep closed.

Colors: outer flame orange 0xff6a12, inner yellow 0xffc94d, core white-ish 0xfff3b0.

I'll create flame group with:
- 6 outer cones (orange), 4 inner (yellow) smaller, 1 core.

Also embers bed: circle of small rocks with emissive material (MeshStandardMaterial emissive 0xff4400, emissiveIntensity varies with flicker).

Light: pointLight at (0, 1.1, 0), color 0xff8b3d, intensity varies 8..16 (with decay 2 and distance 40). Physically correct lighting is default in r155+; intensity in candela. With decay 2, intensity 20 gives decent brightness at distance 3 (20/9≈2.2). Let's use base intensity ~25 and flicker ±.

Actually renderer.useLegacyLights removed in r165; default physically correct. PointLight intensity in candela; at distance 2m, illuminance = I/d² = 25/4 = 6.25 lux-ish → bright enough. Let's set base 30.

Directional moon light: color 0x6a8fd6, intensity 0.25.

Ambient: HemisphereLight sky 0x22305a ground 0x0a0f14 intensity 0.25? HemisphereLight intensity ~0.6 maybe. Let's do ambient 0.35.

Tone mapping: ACESFilmicToneMapping, exposure 1.0, outputColorSpace default SRGB.

Let's write the final code.

For the fire stones ring, place 9 small rocks radius 0.95 around center.

Logs: 5 logs as teepee: cylinder radius 0.16, length 2.4, positioned leaning from ground (radius 1.0) up toward center height 1.4. Compute orientation: log from point A=(cos a*1.1, 0.05, sin a*1.1) to B=(-cos a*0.25, 1.5, -sin a*0.25). Use helper to orient cylinder between two points.

Also 3 fallen logs lying flat at radius 4-7.

Rocks: icosahedron with jittered vertices → need non-indexed for flat facets; use `geometry = new THREE.IcosahedronGeometry(1,0);` then modify position attribute per vertex randomly (that gives non-uniform, still low-poly). Then `geometry.computeVertexNormals()` and flatShading material. Since IcosahedronGeometry is non-indexed already (it's from PolyhedronGeometry, non-indexed), jittering each vertex individually would break shared vertices apart (making cracks). To keep shape coherent, jitter per unique direction: better approach — scale vertices by a random factor based on their normalized direction hash. Since non-indexed with duplicated positions, compute factor from position values deterministically: f = 1 + 0.25*sin(x*12.9+y*78.2+z*37.7) etc. That gives same factor for duplicated identical positions → coherent.

Implement:
```
function rockGeometry(detail){
 const g = new THREE.IcosahedronGeometry(1, detail);
 const p = g.attributes.position;
 for(let i=0;i<p.count;i++){
   const x=p.getX(i), y=p.getY(i), z=p.getZ(i);
   const n = Math.sin(x*12.9898+y*78.233+z*37.719)*43758.5453; const f=1+ (n-Math.floor(n)-0.5)*0.5;
   p.setXYZ(i, x*f, y*f, z*f);
 }
 g.computeVertexNormals(); return g;
}
```
Good.

Trees foliage colors: pick from array [0x1d3b2a, 0x24452f, 0x2a5136, 0x183227].

Now write final. Also add a subtle "fire light on trees" — point light handles.

Ground material with vertexColors: color base multiplied by vertex color; set material.color white 0xffffff and vertex colors carry the actual color.

Compute vertex color: mix of dark moss green and brown, plus darker near center? Near center should be ash/dirt brown (0x3a2f24) and further green (0x1e3324). Plus random per-face variation for low-poly look (use flat shading gives facets).

Let me do per-vertex color from height & radius with slight noise.

Also add scattered grass tufts? Could add small cone clusters near clearing edges — 60 tiny cones. Nice detail. Let's add 80 small grass blades (thin cones/planes) in clearing. Keep simple: small cones (0.03 radius, 0.5 height, 4 seg) dark green, scattered radius 3..15. 80 of them = 80 draw calls. Combined with 46 trees * 4 = ~184 meshes + rocks. Total maybe 350 draw calls — acceptable but shadows double it. Let's reduce: use InstancedMesh for grass? Simpler: reduce grass to 60 and merge grass into one geometry using BufferGeometryUtils? mergeGeometries from addons — allowed URL three/addons/utils/BufferGeometryUtils.js. That's fine (addons path allowed). Let's use it to merge grass and also maybe trees' foliage? Trees need individual? Not really—trees static; could merge all foliage into one geometry for performance, but then per-tree color needs vertex colors. Fine: keep trees as separate meshes for simplicity but reduce count of meshes: each tree = 1 trunk + 3 cones = 4 meshes * 46 = 184. That's OK for Chrome at 60fps? Probably fine. Shadows from point light 6 faces × 184 meshes = heavy-ish. Let's use merged geometry per tree (trunk + cones merged with vertex colors) → 46 meshes. Good idea: build tree geometry merged with vertex colors, one material MeshStandardMaterial({vertexColors:true, flatShading:true}).

Use BufferGeometryUtils.mergeGeometries with geometries having matching attributes (position, normal, uv, color). Cone/Cylinder geometries have position, normal, uv. Add color attribute manually to each. mergeGeometries requires same attributes for all. I'll write helper addColor(geo, hex).

Let's do that for tree (trunk brown + 3 cones green) → single geometry, single mesh, 46 meshes.

Similarly rocks: each rock individual mesh (26 rocks) — fine. Could merge but different scales; use matrix applied then merge: mergeGeometries after applying matrix. Let's merge all rocks into one geometry too (with vertex colors). And logs merged. Great performance.

But shadow casting from merged meshes is fine.

Let's implement helper:
```
function colorize(geo, hex){
  const c = new THREE.Color(hex); const arr=new Float32Array(geo.attributes.position.count*3);
  for(let i=0;i<geo.attributes.position.count;i++){arr[i*3]=c.r;...}
  geo.setAttribute('color', new THREE.BufferAttribute(arr,3));
  return geo;
}
```
Note color space: setColorAt? Using Color(hex) with THREE.ColorManagement enabled: Color constructor from hex converts sRGB → linear working space (since r152, `new THREE.Color(0x...)` applies colorSpace conversion when `setHex` with SRGBColorSpace default). Yes, setHex assumes sRGB and converts to linear-srgb working. Good, so vertex colors in linear space.

mergeGeometries requires geometries have same set of attributes: position, normal, uv, color. Ensure all have uv (cone, cylinder, icosahedron: PolyhedronGeometry has uv? IcosahedronGeometry extends PolyhedronGeometry which generates uv). Yes it has uv.

Also need to apply matrix before merging: geo.applyMatrix4(m) then merge.

Order: create geometry, applyMatrix4, colorize, then merge list.

mergeGeometries(geos, false).

Let's write tree builder:

```
function makeTreeGeometry(rng){
  const parts=[];
  const trunkH = 1.2 + rng()*1.0;
  const trunk = new THREE.CylinderGeometry(0.14,0.24,trunkH,6,1);
  trunk.translate(0, trunkH/2, 0);
  colorize(trunk, 0x3a2a1d);
  parts.push(trunk);
  const layers = 3;
  let y = trunkH*0.75, r = 1.5+rng()*0.5, h = 2.2+rng()*0.8;
  for(let i=0;i<layers;i++){
    const cone = new THREE.ConeGeometry(r, h, 7, 1);
    cone.translate(0, y + h/2, 0);
    colorize(cone, greenPalette[...]);
    parts.push(cone);
    y += h*0.55; r*=0.72; h*=0.85;
  }
  return mergeGeometries(parts, false);
}
```
Then per tree: mesh = new Mesh(treeGeom, treeMaterial) with random rotation Y and scale. But all trees share geometry → same shape unless we vary scale/rotation. To vary shape, create ~5 distinct tree geometries and reuse with random scale/rotation. Good.

Tree material: MeshStandardMaterial({vertexColors:true, flatShading:true, roughness:1}).

Wait: flatShading with vertex colors is fine.

Scale variation per tree: scale.set(s, s*(0.9+rand*0.4), s).

Placement: position y = terrainH.

OK let's write final code. Also note: mergeGeometries import path 'three/addons/utils/BufferGeometryUtils.js'.

Let me now write the whole thing.

One concern: `mergeGeometries` requires geometries with identical attribute sets — after colorize all have position, normal, uv, color. Cone/Cylinder have 'uv'. Icosahedron has 'uv'. Good.

Rock merging: build each rock geometry, applyMatrix4 (rotation+scale+translate), colorize with slight variation, merge.

Logs: cylinder geometry oriented between two points: create CylinderGeometry(r,r,len,7) then applyMatrix4 that translates to midpoint and rotates from Y axis to direction. Use quaternion setFromUnitVectors.

Let me code that.

Also the fire "coals": small icosahedrons with emissive material - separate meshes (8), emissiveIntensity animated with flicker. Merge them but then emissive can't be per-mesh... just make one merged mesh with emissive material, and animate material.emissiveIntensity — fine, one mesh.

Ember bed: CircleGeometry? Use merged small rocks with colorize dark + separate emissive. I'll make a "coals" merged geometry colored 0x220a00 and use MeshStandardMaterial with emissive 0xff5500, emissiveIntensity 1.5, flatShading.

Now final code writing.

Also add distant tree silhouette ring at radius 60-85, smaller count (30) — merged into one mesh.

Fog: FogExp2(0x070b14, 0.022). At distance 60, factor = 1-exp(-(0.022*60)^2)= 1-exp(-1.74)=0.82 → heavily fogged. Good. Trees at radius 9-45 will be partially fogged. At 30: (0.66)^2=0.435 → 35% fogged. Reasonable.

Stars fog:false so visible.

Moon: fog false.

Let me write.

Hint overlay: "Drag to orbit · Scroll to zoom" plus maybe title. Also a small title in corner "Campfire". Keep minimal, fade hint after 6s to 0.35 opacity. Actually keep visible at low opacity always.

Let me also handle prefers-reduced-motion? Not necessary.

Write final code.

For sparks update with color fade: colors array per point base color (1.0,0.6,0.2) times life factor.

Implementation:
```
const SPARKS=160;
const sparkPos = new Float32Array(SPARKS*3);
const sparkCol = new Float32Array(SPARKS*3);
const sparkVel = new Float32Array(SPARKS*3);
const sparkLife = new Float32Array(SPARKS);
function respawn(i){ ... set pos near fire top, vel random }
```
Reset life = 0 initially, random offsets.

Update each frame:
life += dt; if life>maxLife respawn.
vel.y += buoyancy? Sparks decelerate: vel.y *= 0.985; vel.x += sin drift.
pos += vel*dt.
alpha = 1 - life/maxLife; color = base*alpha^0.7.

Also point size: can't vary per point with PointsMaterial. Fine.

Add a second glow sprite at fire for halo: Sprite with additive radial gradient, scale 6, at fire, with slight flicker opacity.

Also add ground fog? Skip.

Let me write code now for real.

I also want the flame to have a bit of translucent look: use MeshBasicMaterial with opacity 0.75 and additive blending — overlapping cones give bright core. Good.

Also add a "glow" mesh: IcosahedronGeometry(0.5) with additive orange, pulsing.

Final code below.

Potential issue: `renderer.shadowMap.type = THREE.PCFSoftShadowMap`.

Point light shadow: `light.shadow.mapSize.set(1024,1024); light.shadow.camera.near=0.3; light.shadow.camera.far=40; light.shadow.bias=-0.006;`

Directional shadow: camera ortho -25..25, near 1 far 120, position moon direction.

Make sure ground receives shadow: ground.receiveShadow = true; trees castShadow true.

Alright.

Let me write the code in full.

```js
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
```

RNG:
```js
let seed = 1337;
function rand(){ seed = (seed*1664525+1013904223)>>>0; return seed/4294967296; }
function randRange(a,b){return a+ (b-a)*rand();}
```

Smoothstep helper.

Terrain.

Then build.

Let me make sure the clearing radius ~8 free of trees but rocks/logs around.

Writing now. I'll be thorough but concise.

Camera start: position (7.5, 3.2, 11) looking at fire.

Also add slight camera bob? no.

Final answer.