Models 44

DeepSeek V4.1 Flash 1017

by DeepSeekReasoningClean
Open in new window

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

How it went

Total time
3 min 34 s
Cost
$0.017
Output tokens
33,746
Spent thinking
21,638 tokens
Speed
158.2 tok/s
Console errors
0
First token after
624 ms
First visible text after
2 min 58 s
Prompt tokens
598
Code size
22.8 KB
Finish reason
stop
Served by
Relace
Model id
deepseek/deepseek-v4.1-flash-1017
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 · 652 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Campfire in the Clearing</title>
<style>
  html, body { margin: 0; padding: 0; height: 100%; background: #05070e; overflow: hidden; }
  canvas { display: block; }
  #vignette {
    position: fixed; inset: 0; pointer-events: none;
    background: radial-gradient(ellipse at 50% 58%, rgba(0,0,0,0) 32%, rgba(0,0,0,.62) 100%);
  }
  #hint {
    position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%);
    color: #c8d6ea;
    font: 500 12px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
    letter-spacing: .18em; text-transform: uppercase; opacity: .82;
    pointer-events: none; white-space: nowrap;
    text-shadow: 0 1px 14px rgba(0,0,0,.95);
    transition: opacity 1.2s ease;
  }
</style>
</head>
<body>
<div id="vignette"></div>
<div id="hint">Drag to look around</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';

/* ------------------------------------------------------------------ */
/*  Helpers                                                            */
/* ------------------------------------------------------------------ */
function mulberry32(a) {
  return function () {
    a |= 0; a = (a + 0x6D2B79F5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const rand = mulberry32(20240613);
const rnd = (a, b) => a + (b - a) * rand();

function paint(geo, hex) {
  const c = new THREE.Color(hex);
  const n = geo.attributes.position.count;
  const arr = new Float32Array(n * 3);
  for (let i = 0; i < n; i++) {
    arr[i * 3] = c.r; arr[i * 3 + 1] = c.g; arr[i * 3 + 2] = c.b;
  }
  geo.setAttribute('color', new THREE.BufferAttribute(arr, 3));
  return geo;
}

function radialTexture(stops, size) {
  size = size || 128;
  const cv = document.createElement('canvas');
  cv.width = cv.height = size;
  const ctx = cv.getContext('2d');
  const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
  for (let i = 0; i < stops.length; i++) g.addColorStop(stops[i].o, stops[i].c);
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, size, size);
  const tex = new THREE.CanvasTexture(cv);
  tex.colorSpace = THREE.SRGBColorSpace;
  return tex;
}

/* ------------------------------------------------------------------ */
/*  Renderer / scene / camera                                          */
/* ------------------------------------------------------------------ */
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.12;
document.body.appendChild(renderer.domElement);

const FOG_COLOR = 0x121c2e;

const scene = new THREE.Scene();
scene.background = new THREE.Color(FOG_COLOR);
scene.fog = new THREE.Fog(FOG_COLOR, 24, 110);

const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 1400);
camera.position.set(5.6, 3.4, 7.4);

/* ------------------------------------------------------------------ */
/*  Night sky gradient                                                 */
/* ------------------------------------------------------------------ */
const skyMat = new THREE.ShaderMaterial({
  side: THREE.BackSide,
  depthWrite: false,
  uniforms: {
    topColor:    { value: new THREE.Color(0x01030a) },
    midColor:    { value: new THREE.Color(0x0a1425) },
    bottomColor: { value: new THREE.Color(0x121c2e) }
  },
  vertexShader: `
    varying vec3 vPos;
    void main() {
      vPos = position;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }`,
  fragmentShader: `
    uniform vec3 topColor;
    uniform vec3 midColor;
    uniform vec3 bottomColor;
    varying vec3 vPos;
    void main() {
      float h = normalize(vPos).y;
      vec3 c = mix(bottomColor, midColor, smoothstep(-0.05, 0.30, h));
      c = mix(c, topColor, smoothstep(0.22, 0.85, h));
      gl_FragColor = vec4(c, 1.0);
      #include <tonemapping_fragment>
      #include <colorspace_fragment>
    }`
});
const sky = new THREE.Mesh(new THREE.SphereGeometry(600, 32, 16), skyMat);
sky.frustumCulled = false;
scene.add(sky);

/* ------------------------------------------------------------------ */
/*  Stars                                                              */
/* ------------------------------------------------------------------ */
const STAR_COUNT = 1700;
const starPos = new Float32Array(STAR_COUNT * 3);
const starColA = new Float32Array(STAR_COUNT * 3);
for (let i = 0; i < STAR_COUNT; i++) {
  const y = 0.02 + rand() * 0.98;
  const r = Math.sqrt(Math.max(0, 1 - y * y));
  const a = rand() * Math.PI * 2;
  const R = 470;
  starPos[i * 3] = Math.cos(a) * r * R;
  starPos[i * 3 + 1] = y * R;
  starPos[i * 3 + 2] = Math.sin(a) * r * R;
  const b = 0.28 + Math.pow(rand(), 2.1) * 0.72;
  starColA[i * 3] = b * 0.92;
  starColA[i * 3 + 1] = b * 0.96;
  starColA[i * 3 + 2] = b;
}
const starGeo = new THREE.BufferGeometry();
starGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3));
starGeo.setAttribute('color', new THREE.BufferAttribute(starColA, 3));
const starTex = radialTexture([
  { o: 0, c: 'rgba(255,255,255,1)' },
  { o: 0.35, c: 'rgba(255,255,255,0.5)' },
  { o: 1, c: 'rgba(255,255,255,0)' }
], 64);
const starMat = new THREE.PointsMaterial({
  size: 3.0,
  sizeAttenuation: false,
  map: starTex,
  vertexColors: true,
  transparent: true,
  depthWrite: false,
  blending: THREE.AdditiveBlending,
  fog: false,
  toneMapped: false
});
const stars = new THREE.Points(starGeo, starMat);
stars.frustumCulled = false;
scene.add(stars);

/* ------------------------------------------------------------------ */
/*  Moon                                                               */
/* ------------------------------------------------------------------ */
const moonDir = new THREE.Vector3(-0.52, 0.40, -0.75).normalize();
const moonPos = moonDir.clone().multiplyScalar(420);

const moon = new THREE.Mesh(
  new THREE.SphereGeometry(16, 24, 16),
  new THREE.MeshBasicMaterial({ color: 0xe9efff, fog: false, toneMapped: false })
);
moon.position.copy(moonPos);
scene.add(moon);

const glowTex = radialTexture([
  { o: 0, c: 'rgba(210,228,255,0.75)' },
  { o: 0.18, c: 'rgba(160,196,255,0.32)' },
  { o: 0.48, c: 'rgba(90,130,215,0.09)' },
  { o: 1, c: 'rgba(0,0,0,0)' }
], 256);
const moonGlow = new THREE.Sprite(new THREE.SpriteMaterial({
  map: glowTex,
  transparent: true,
  blending: THREE.AdditiveBlending,
  depthWrite: false,
  fog: false,
  toneMapped: false,
  opacity: 0.95
}));
moonGlow.position.copy(moonPos.clone().multiplyScalar(0.99));
moonGlow.scale.set(170, 170, 1);
scene.add(moonGlow);

/* ------------------------------------------------------------------ */
/*  Ambient / moonlight                                                */
/* ------------------------------------------------------------------ */
const hemi = new THREE.HemisphereLight(0x35507a, 0x0a0d08, 1.15);
scene.add(hemi);

const moonLight = new THREE.DirectionalLight(0x9dc0ff, 1.5);
moonLight.position.copy(moonDir.clone().multiplyScalar(120));
scene.add(moonLight);

/* ------------------------------------------------------------------ */
/*  Terrain                                                            */
/* ------------------------------------------------------------------ */
function terrainNoise(x, z) {
  return Math.sin(x * 0.13) * Math.cos(z * 0.11) * 1.0
       + Math.sin(x * 0.052 + 1.7) * Math.cos(z * 0.068 - 0.6) * 1.7
       + Math.sin((x + z) * 0.023 + 0.4) * 2.3;
}
function groundHeight(x, z) {
  const d = Math.hypot(x, z);
  const f = THREE.MathUtils.smoothstep(d, 4.5, 20);
  return terrainNoise(x, z) * f * 0.42;
}

const groundGeo = new THREE.PlaneGeometry(260, 260, 72, 72);
groundGeo.rotateX(-Math.PI / 2);
const gp = groundGeo.attributes.position;
const gColors = new Float32Array(gp.count * 3);
const colGrassLit = new THREE.Color(0x3a4a2c);
const colGrassDark = new THREE.Color(0x141c11);
const colDirt = new THREE.Color(0x3b3423);
const tmpCol = new THREE.Color();

for (let i = 0; i < gp.count; i++) {
  const x = gp.getX(i);
  const z = gp.getZ(i);
  const d = Math.hypot(x, z);
  gp.setY(i, groundHeight(x, z));

  const hMix = THREE.MathUtils.clamp(terrainNoise(x, z) * 0.32 + 0.5, 0, 1);
  tmpCol.copy(colGrassDark).lerp(colGrassLit, 0.15 + hMix * 0.85);

  const dirt = 1 - THREE.MathUtils.smoothstep(d, 3.0, 9.5);
  tmpCol.lerp(colDirt, dirt * 0.85);

  const shade = 0.86 + rand() * 0.28;
  gColors[i * 3] = tmpCol.r * shade;
  gColors[i * 3 + 1] = tmpCol.g * shade;
  gColors[i * 3 + 2] = tmpCol.b * shade;
}
groundGeo.setAttribute('color', new THREE.BufferAttribute(gColors, 3));
groundGeo.computeVertexNormals();

const ground = new THREE.Mesh(groundGeo, new THREE.MeshStandardMaterial({
  vertexColors: true,
  roughness: 1,
  metalness: 0,
  flatShading: true
}));
ground.receiveShadow = true;
scene.add(ground);

/* ------------------------------------------------------------------ */
/*  Trees (merged into a single mesh)                                  */
/* ------------------------------------------------------------------ */
const treeParts = [];
const treeGreens = [0x1f3a24, 0x27462a, 0x18301d, 0x2d5033, 0x213f26, 0x2a4a2e];

function buildTree(x, z, s, green) {
  const baseY = groundHeight(x, z) - 0.35;
  const parts = [];

  const trunkH = 1.8 * s;
  const trunk = new THREE.CylinderGeometry(0.12 * s, 0.22 * s, trunkH, 5, 1);
  trunk.translate(0, trunkH / 2, 0);
  parts.push(paint(trunk, 0x2b1f16));

  const layers = [
    { r: 1.60, h: 2.40, y: 0.80 },
    { r: 1.25, h: 2.00, y: 1.85 },
    { r: 0.90, h: 1.70, y: 2.85 }
  ];
  for (let i = 0; i < layers.length; i++) {
    const L = layers[i];
    const g = new THREE.ConeGeometry(L.r * s, L.h * s, 6, 1);
    g.translate(0, (L.y + L.h / 2) * s, 0);
    parts.push(paint(g, green));
  }

  const m = new THREE.Matrix4().makeRotationY(rand() * Math.PI * 2);
  m.setPosition(x, baseY, z);
  for (let i = 0; i < parts.length; i++) {
    parts[i].applyMatrix4(m);
    treeParts.push(parts[i]);
  }
}

const TREE_COUNT = 72;
for (let i = 0; i < TREE_COUNT; i++) {
  const a = rand() * Math.PI * 2;
  const radius = 11.5 + Math.pow(rand(), 1.45) * 74;   // 11.5 .. 85.5
  const x = Math.cos(a) * radius;
  const z = Math.sin(a) * radius;
  buildTree(x, z, rnd(0.75, 1.95), treeGreens[Math.floor(rand() * treeGreens.length)]);
}

const treeMesh = new THREE.Mesh(
  mergeGeometries(treeParts, false),
  new THREE.MeshStandardMaterial({ vertexColors: true, flatShading: true, roughness: 1, metalness: 0 })
);
treeMesh.castShadow = true;
treeMesh.receiveShadow = true;
scene.add(treeMesh);

/* ------------------------------------------------------------------ */
/*  Rocks + fire-ring stones                                           */
/* ------------------------------------------------------------------ */
const rockParts = [];

function addRock(x, z, size, hex) {
  const g = new THREE.IcosahedronGeometry(size, 0);
  const m = new THREE.Matrix4();
  m.makeRotationFromEuler(new THREE.Euler(rnd(-0.35, 0.35), rand() * Math.PI * 2, rnd(-0.35, 0.35)));
  m.scale(new THREE.Vector3(rnd(0.8, 1.35), rnd(0.55, 1.0), rnd(0.8, 1.35)));
  m.setPosition(x, groundHeight(x, z) + size * 0.28, z);
  g.applyMatrix4(m);
  rockParts.push(paint(g, hex));
}

const rockHexes = [0x4a4a52, 0x3c3c45, 0x555049, 0x343a3e];

// scattered rocks
for (let i = 0; i < 18; i++) {
  const a = rand() * Math.PI * 2;
  const r = 2.4 + Math.pow(rand(), 0.8) * 8.5;
  addRock(Math.cos(a) * r, Math.sin(a) * r, rnd(0.18, 0.75), rockHexes[Math.floor(rand() * rockHexes.length)]);
}
// a couple of bigger boulders
for (let i = 0; i < 4; i++) {
  const a = rand() * Math.PI * 2;
  const r = rnd(7.5, 13);
  addRock(Math.cos(a) * r, Math.sin(a) * r, rnd(0.9, 1.6), 0x43434c);
}
// fire ring stones
for (let i = 0; i < 9; i++) {
  const a = (i / 9) * Math.PI * 2 + 0.22;
  const r = 1.32;
  addRock(Math.cos(a) * r, Math.sin(a) * r, rnd(0.17, 0.25), 0x4d4a48);
}

const rockMesh = new THREE.Mesh(
  mergeGeometries(rockParts, false),
  new THREE.MeshStandardMaterial({ vertexColors: true, flatShading: true, roughness: 1, metalness: 0 })
);
rockMesh.castShadow = true;
rockMesh.receiveShadow = true;
scene.add(rockMesh);

/* ------------------------------------------------------------------ */
/*  Wood: fallen logs + the burning teepee                             */
/* ------------------------------------------------------------------ */
const woodParts = [];

// fallen logs around the clearing
for (let i = 0; i < 6; i++) {
  const a = rand() * Math.PI * 2;
  const r = rnd(3.2, 10.0);
  const x = Math.cos(a) * r;
  const z = Math.sin(a) * r;
  const len = rnd(2.2, 4.6);
  const rad = rnd(0.16, 0.30);
  const g = new THREE.CylinderGeometry(rad * 0.85, rad, len, 7, 1);
  g.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2));
  g.applyMatrix4(new THREE.Matrix4().makeRotationY(rand() * Math.PI * 2));
  g.translate(x, groundHeight(x, z) + rad * 0.8, z);
  woodParts.push(paint(g, 0x32251b));
}

// burning teepee logs
const LOG_N = 5;
for (let i = 0; i < LOG_N; i++) {
  const a = (i / LOG_N) * Math.PI * 2 + 0.35;
  const g = new THREE.CylinderGeometry(0.085, 0.115, 1.55, 6, 1);
  g.translate(0, 0.775, 0);
  g.applyMatrix4(new THREE.Matrix4().makeRotationX(0.62));
  g.applyMatrix4(new THREE.Matrix4().makeRotationY(a));
  g.translate(-0.42 * Math.sin(a), 0.02, -0.42 * Math.cos(a));
  woodParts.push(paint(g, 0x26190f));
}

const woodMesh = new THREE.Mesh(
  mergeGeometries(woodParts, false),
  new THREE.MeshStandardMaterial({ vertexColors: true, flatShading: true, roughness: 0.95, metalness: 0 })
);
woodMesh.castShadow = true;
woodMesh.receiveShadow = true;
scene.add(woodMesh);

/* ------------------------------------------------------------------ */
/*  Fire point light                                                   */
/* ------------------------------------------------------------------ */
const FIRE_LIGHT_BASE = 44;
const fireLight = new THREE.PointLight(0xff8a3c, FIRE_LIGHT_BASE, 70, 2);
fireLight.position.set(0, 1.5, 0);
fireLight.castShadow = true;
fireLight.shadow.mapSize.set(1024, 1024);
fireLight.shadow.camera.near = 0.4;
fireLight.shadow.camera.far = 44;
fireLight.shadow.bias = -0.0035;
fireLight.shadow.normalBias = 0.035;
scene.add(fireLight);

// a soft warm bounce right at the fire base
const fireBounce = new THREE.PointLight(0xff6a20, 12, 9, 2);
fireBounce.position.set(0, 0.45, 0);
scene.add(fireBounce);

/* ------------------------------------------------------------------ */
/*  Flames                                                             */
/* ------------------------------------------------------------------ */
function flameGeometry(r, h) {
  const g = new THREE.ConeGeometry(r, h, 6, 3, false);
  const p = g.attributes.position;
  for (let i = 0; i < p.count; i++) {
    const y = p.getY(i);
    const k = (y + h / 2) / h;            // 0 at base, 1 at tip
    const j = (1 - k) * 0.55 + 0.08;
    p.setX(i, p.getX(i) + (rand() - 0.5) * r * 0.38 * j);
    p.setZ(i, p.getZ(i) + (rand() - 0.5) * r * 0.38 * j);
    p.setY(i, y + (rand() - 0.5) * h * 0.06);
  }
  g.translate(0, h / 2, 0);
  return g;
}

const flameDefs = [
  { x: 0.00, z: 0.00, r: 0.62, h: 2.05, y: 0.04, color: 0xff3c00, opacity: 0.52, sp: 1.7 },
  { x: 0.15, z: -0.11, r: 0.46, h: 1.70, y: 0.09, color: 0xff7200, opacity: 0.60, sp: 2.1 },
  { x: -0.17, z: 0.09, r: 0.40, h: 1.45, y: 0.11, color: 0xff9e14, opacity: 0.60, sp: 2.6 },
  { x: 0.05, z: 0.17, r: 0.30, h: 1.18, y: 0.13, color: 0xffc44a, opacity: 0.62, sp: 3.2 },
  { x: -0.07, z: -0.13, r: 0.20, h: 0.86, y: 0.15, color: 0xffe9a6, opacity: 0.78, sp: 3.8 }
];

const flames = [];
for (let i = 0; i < flameDefs.length; i++) {
  const d = flameDefs[i];
  const mat = new THREE.MeshBasicMaterial({
    color: d.color,
    transparent: true,
    opacity: d.opacity,
    blending: THREE.AdditiveBlending,
    depthWrite: false,
    side: THREE.DoubleSide,
    fog: false,
    toneMapped: false
  });
  const mesh = new THREE.Mesh(flameGeometry(d.r, d.h), mat);
  mesh.position.set(d.x, d.y, d.z);
  mesh.renderOrder = 3;
  scene.add(mesh);
  flames.push({ mesh: mesh, def: d, phase: rand() * Math.PI * 2, bx: d.x, bz: d.z });
}

function updateFlames(t) {
  for (let i = 0; i < flames.length; i++) {
    const f = flames[i];
    const d = f.def;
    const p = f.phase;
    const flick = 0.82 + 0.20 * Math.sin(t * d.sp + p) + 0.10 * Math.sin(t * d.sp * 2.7 + p * 1.7);
    const w = 0.88 + 0.14 * Math.sin(t * d.sp * 1.6 + p * 2.1);

    f.mesh.scale.set(d.r * w, d.h * flick, d.r * w);
    f.mesh.position.x = f.bx + Math.sin(t * 2.4 + p) * 0.07;
    f.mesh.position.z = f.bz + Math.cos(t * 2.1 + p * 1.3) * 0.07;
    f.mesh.position.y = d.y + Math.sin(t * 3.3 + p) * 0.04;
    f.mesh.rotation.y = t * 0.9 + p;
    f.mesh.material.opacity = d.opacity * (0.72 + 0.28 * Math.sin(t * d.sp * 1.3 + p));
  }
}

/* ------------------------------------------------------------------ */
/*  Sparks                                                             */
/* ------------------------------------------------------------------ */
const SPARK_COUNT = 320;
const sparkPos = new Float32Array(SPARK_COUNT * 3);
const sparkColArr = new Float32Array(SPARK_COUNT * 3);
const sparkVel = new Float32Array(SPARK_COUNT * 3);
const sparkLife = new Float32Array(SPARK_COUNT);
const sparkMax = new Float32Array(SPARK_COUNT);

function resetSpark(i, warm) {
  const i3 = i * 3;
  const a = rand() * Math.PI * 2;
  const r = Math.sqrt(rand()) * 0.62;
  sparkPos[i3] = Math.cos(a) * r;
  sparkPos[i3 + 1] = rnd(0.12, 0.85);
  sparkPos[i3 + 2] = Math.sin(a) * r;
  sparkVel[i3] = rnd(-0.38, 0.38);
  sparkVel[i3 + 1] = rnd(1.0, 2.8);
  sparkVel[i3 + 2] = rnd(-0.38, 0.38);
  sparkMax[i] = rnd(1.1, 2.9);
  sparkLife[i] = warm ? rand() * sparkMax[i] : 0;
}
for (let i = 0; i < SPARK_COUNT; i++) resetSpark(i, true);

const sparkGeo = new THREE.BufferGeometry();
sparkGeo.setAttribute('position', new THREE.BufferAttribute(sparkPos, 3));
sparkGeo.setAttribute('color', new THREE.BufferAttribute(sparkColArr, 3));

const sparkTex = radialTexture([
  { o: 0, c: 'rgba(255,255,255,1)' },
  { o: 0.3, c: 'rgba(255,245,220,0.65)' },
  { o: 1, c: 'rgba(255,220,160,0)' }
], 64);

const sparkMat = new THREE.PointsMaterial({
  size: 0.095,
  map: sparkTex,
  vertexColors: true,
  transparent: true,
  blending: THREE.AdditiveBlending,
  depthWrite: false,
  sizeAttenuation: true,
  fog: false,
  toneMapped: false
});

const sparks = new THREE.Points(sparkGeo, sparkMat);
sparks.frustumCulled = false;
scene.add(sparks);

function updateSparks(dt, t) {
  for (let i = 0; i < SPARK_COUNT; i++) {
    const i3 = i * 3;
    sparkLife[i] += dt;
    if (sparkLife[i] >= sparkMax[i]) resetSpark(i, false);

    const lifeT = sparkLife[i] / sparkMax[i];

    sparkVel[i3 + 1] -= 0.42 * dt;
    const swirlX = Math.sin(t * 2.6 + i * 0.73) * 0.42;
    const swirlZ = Math.cos(t * 2.2 + i * 0.91) * 0.42;

    sparkPos[i3] += (sparkVel[i3] + swirlX) * dt;
    sparkPos[i3 + 1] += sparkVel[i3 + 1] * dt;
    sparkPos[i3 + 2] += (sparkVel[i3 + 2] + swirlZ) * dt;

    const b = Math.pow(1 - lifeT, 1.7);
    sparkColArr[i3] = b;
    sparkColArr[i3 + 1] = b * (0.75 - 0.50 * lifeT);
    sparkColArr[i3 + 2] = b * 0.25 * (1 - lifeT);
  }
  sparkGeo.attributes.position.needsUpdate = true;
  sparkGeo.attributes.color.needsUpdate = true;
}

// warm up so the sparks are already spread out on the first frame
for (let k = 0; k < 200; k++) updateSparks(1 / 60, k / 60);

/* ------------------------------------------------------------------ */
/*  Light flicker                                                      */
/* ------------------------------------------------------------------ */
function updateLight(t) {
  const flick = 0.84
    + 0.16 * Math.sin(t * 11.3)
    + 0.10 * Math.sin(t * 7.1 + 1.7)
    + 0.07 * Math.sin(t * 23.7 + 0.4);
  fireLight.intensity = FIRE_LIGHT_BASE * THREE.MathUtils.clamp(flick, 0.55, 1.22);
  fireLight.position.set(
    Math.sin(t * 3.1) * 0.10,
    1.45 + Math.sin(t * 4.7) * 0.07,
    Math.cos(t * 2.7) * 0.10
  );
  fireBounce.intensity = 12 * THREE.MathUtils.clamp(flick, 0.6, 1.25);
}

/* ------------------------------------------------------------------ */
/*  Controls                                                           */
/* ------------------------------------------------------------------ */
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1.35, 0);
controls.enableDamping = true;
controls.dampingFactor = 0.075;
controls.rotateSpeed = 0.6;
controls.zoomSpeed = 0.7;
controls.enablePan = false;
controls.minDistance = 3.5;
controls.maxDistance = 34;
controls.minPolarAngle = 0.28;
controls.maxPolarAngle = Math.PI * 0.495;   // stay above the ground
controls.autoRotate = true;
controls.autoRotateSpeed = 0.45;
controls.addEventListener('start', () => { controls.autoRotate = false; });
controls.update();

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

/* ------------------------------------------------------------------ */
/*  Animation loop                                                     */
/* ------------------------------------------------------------------ */
const clock = new THREE.Clock();
let elapsed = 0;

function animate() {
  requestAnimationFrame(animate);

  const dt = Math.min(clock.getDelta(), 0.05);
  elapsed += dt;

  updateFlames(elapsed);
  updateSparks(dt, elapsed);
  updateLight(elapsed);

  // subtle star twinkle
  starMat.opacity = 0.88 + 0.12 * Math.sin(elapsed * 1.7);

  controls.update();

  // safety clamp: never let the camera slip below the ground
  if (camera.position.y < 1.05) camera.position.y = 1.05;

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

/* ------------------------------------------------------------------ */
/*  Hint fade                                                          */
/* ------------------------------------------------------------------ */
setTimeout(() => {
  const hint = document.getElementById('hint');
  if (hint) hint.style.opacity = '0';
}, 9000);
</script>
</body>
</html>
Reasoning trace (21,638 tokens)
Let me build a single HTML file with Three.js sphere.

Key elements:
- Scene with fog, dark night background
- Ground: low-poly plane with slight noise displacement, dark green/brown
- Rocks: icosahedron/dodecahedron with flat shading, random scale/rotation
- Logs: cylinders around fire
- 30+ trees: cone trunk + cone foliage (low-poly), placed randomly outside clearing radius
- Campfire: stones ring, logs, flames as animated cones/icosahedrons with additive material, flickering point light with shadows
- Sparks: BufferGeometry points, rising, reset
- Stars: Points on a large sphere
- Moon: sphere with emissive, plus glow sprite maybe (avoid external textures - can use canvas-generated texture, that's fine since it's inline)
- OrbitControls with maxPolarAngle limits and minDistance/maxDistance

Let's write clean code.

Use flat shading for low-poly look. Use MeshStandardMaterial with flatShading true.

Ground: PlaneGeometry(200,200, 64,64) rotated, with vertex displacement noise for low-poly hills. Use flatShading. Colors dark green. Actually a huge plane with displacement... camera at ~10 units. Let's make ground 160x160 with 48 segments, displace with simple pseudo-noise. Keep center flat-ish so fire sits nicely.

Trees: generate with cone geometry for foliage (3 stacked cones) and cylinder trunk. Use InstancedMesh? Simpler to just create meshes and merge... 40 trees * 4 meshes = 160 meshes. Fine.

Actually for performance and shadows, shadows only from the fire point light — point light shadows are 6 faces. Keep shadow map 1024. Limit shadow casting to nearby objects: trees cast shadows? That could be heavy. Let's have trees castShadow = false for far ones, true for near? PointLight shadow uses a cube map, and all objects in range. Set shadow camera far ~40. Reasonable.

Let's just enable castShadow on ground (receive only), rocks, logs, fire logs, trees near the clearing. Actually let's set castShadow true for everything but keep the shadow far small (30). Hmm, 160 meshes rendered 6 times = 960 draws for shadows. That's heavy-ish but probably OK at 1024 cube. Let's reduce: use merged geometry? I don't want to import BufferGeometryUtils... actually the import map allows three/addons/, so I can import mergeGeometries from 'three/addons/utils/BufferGeometryUtils.js'. That's fine and allowed.

Plan: build tree geometries merged into one big geometry per "type" or one merged geometry for all trees → single mesh. But then shadows for trees are one draw. Good. However merged trees can't cast individual shadows differently, but fine.

Actually simpler: create per-tree groups but set castShadow only on trunks/foliage for a subset? Let's just merge all trees into one geometry (using BufferGeometryUtils.mergeGeometries after applying matrices). That gives 1 draw call. Nice for perf.

But flat shading with vertex colors? Merged geometry with different materials — I can use vertex colors. Let's use a single material with vertexColors true, flatShading true. Set color attribute per geometry before merging.

Hmm, but different colors for trunk (brown) and foliage (dark green variations). With vertex colors that works: set attribute 'color' on each geometry.

Let me write helper:

```js
function colorize(geo, color){
  const c = new THREE.Color(color);
  const count = geo.attributes.position.count;
  const arr = new Float32Array(count*3);
  for(let i=0;i<count;i++){ arr[i*3]=c.r; arr[i*3+1]=c.g; arr[i*3+2]=c.b; }
  geo.setAttribute('color', new THREE.BufferAttribute(arr,3));
  return geo;
}
```

Then build tree: cone geometries translated & rotated, apply matrix, colorize, push to array.

Then mergeGeometries(list) → one mesh with material {vertexColors:true, flatShading:true}.

Note: merging requires attributes consistent — all have position, normal, uv (from ConeGeometry). color added. Good. ConeGeometry has uv. CylinderGeometry has uv. OK.

Rocks: icosahedron with randomized vertices, flat shading, gray. Merge into one mesh too? Rocks near fire need to cast shadows — merged mesh works, single mesh casts shadow fine.

Fire: 
- Fire pit: ring of small stones (about 8-10) around radius 1.2.
- Logs: 4 cylinders crossing.
- Flames: several cone/icosahedron meshes with MeshBasicMaterial additive, animated scale/rotation/opacity. Use a few layered cones with different colors (orange, yellow, red). Also could use Points-based flame sprite with canvas texture. Let's do meshes: 5-7 flame tongues, each a cone with vertex jitter, additive blending, no depth write.

Actually a nicer flame: use a cone geometry with modified vertices, animate scale.y and random rotation, using MeshBasicMaterial with transparent + additive.

Also add "light flicker": point light intensity varies with noise.

Sparks: Points with BufferGeometry of N=200 particles, additive material with round canvas texture, size attenuation. Each particle has velocity upward + slight drift, life. Reset when life ends.

Stars: 1500 points on sphere radius 300, white-ish with random colors, size attenuation false (size small, sizeAttenuation false so dots). Use PointsMaterial size 1.5 sizeAttenuation false? With a canvas circular texture, sizeAttenuation false means size in px. Good.

Moon: SphereGeometry radius 12 at distance 250, MeshBasicMaterial white-ish, plus a glow: a sprite with radial gradient canvas texture, or just a few transparent circles. Let's use a Sprite with a canvas radial gradient texture, additive blending.

Fog: THREE.FogExp2 or Fog. Scene.fog = new THREE.Fog(0x0a1020, 20, 90). Distant trees fade. But stars/moon are far — fog would hide them since fog doesn't apply to Points? Fog applies to Points material with fog:true default. Set fog:false on stars/moon materials. Also set fog:false on moon sprite.

Background: scene.background = color 0x05070f. With fog color same-ish for blending.

Camera: perspective, position (8, 5, 10), lookAt (0,1.5,0). OrbitControls target (0,1.5,0), minDistance 5, maxDistance 45, maxPolarAngle ~ Math.PI*0.49 to keep above ground, enableDamping.

Also enforce camera.position.y > 0.6 in animate.

Renderer: antialias, shadowMap enabled PCFSoft, tone mapping ACESFilmic, exposure ~1.1. pixelRatio min(devicePixelRatio,2).

Resize handler.

Import map + module script.

Also need "Click or press a key to start"? The task says games must start in demo mode. This isn't a game; it's a scene with OrbitControls. But keyboard events... OrbitControls only needs mouse. However the iframe only receives events after click — I should still show a hint overlay? The rule is about games. For this scene, orbit controls work with mouse drag which requires a click anyway. I'll add a subtle title/hint overlay that fades: "Drag to look around · Scroll to zoom". Maybe add a start overlay that says "Click or drag to explore" — but that could block the screenshot? The screenshot is taken 3 s after load and the scene is visible behind a semi-transparent overlay. Better: show a small hint at the bottom that fades out after a few seconds. Actually keep it simple: non-blocking hint text.

Let me also add a subtle fire glow ambient light.

Let's write the code.

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Campfire</title>
<style>
  html,body{margin:0;height:100%;background:#05070f;overflow:hidden;font-family:system-ui,sans-serif}
  canvas{display:block}
  #hint{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);color:#cfd8e6;font-size:13px;letter-spacing:.14em;text-transform:uppercase;opacity:.75;pointer-events:none;text-shadow:0 0 12px #000;transition:opacity 1s ease;white-space:nowrap}
  #vignette{position:fixed;inset:0;pointer-events:none;background:radial-gradient(ellipse at 50% 55%,rgba(0,0,0,0) 40%,rgba(0,0,0,.55) 100%)}
</style>
</head>
<body>
<div id="vignette"></div>
<div id="hint">Drag to look · Scroll to zoom</div>
<script type="importmap">...</script>
<script type="module">
...
</script>
</body>
</html>
```

Now the JS.

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

Renderer setup.

Scene:
```js
const scene = new THREE.Scene();
const NIGHT = 0x070a14;
scene.background = new THREE.Color(NIGHT);
scene.fog = new THREE.Fog(0x0a1220, 22, 95);
```

Hmm fog color slightly blue. Distant trees fade into that. Background is darker than fog... that mismatch might be visible at the horizon. Better set background = fog color? Then sky is 0x0a1220, dark blue, and stars are visible on top. Let's set background to the fog color for coherence. But a night sky gradient would be nicer. I could use a large sphere with a gradient shader material (inside-out) — fog:false. Let's do that: a big sphere radius 400 with ShaderMaterial gradient from dark blue at horizon to near-black at top, BackSide, fog false, depthWrite false. Then fog color ~ horizon color.

Let me do gradient: bottom color 0x1a2740 (horizon), top 0x02030a. And fog color 0x141f33. Hmm fog on distant trees should match horizon-ish. Use fog color 0x111a2b.

Let's compute: horizon color at the ground level. Fog affects trees near horizon. Setting fog color = 0x16233a maybe.

Fine.

Sky sphere shader:

```glsl
varying vec3 vWorldPos;
void main(){ vWorldPos = (modelMatrix*vec4(position,1.0)).xyz; gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0); }
```
frag: 
```glsl
uniform vec3 top; uniform vec3 bottom; varying vec3 vWorldPos;
void main(){ float h = normalize(vWorldPos).y; float t = smoothstep(-0.15, 0.65, h); gl_FragColor = vec4(mix(bottom, top, t), 1.0); }
```

Good.

Ground: PlaneGeometry(220, 220, 60, 60), rotateX(-PI/2). Displace y with noise: for each vertex, compute radius; near center keep flat (within radius 6 multiply displacement by smoothstep). Use value noise from sin combos.

```js
function noise2(x,z){
  return Math.sin(x*0.35)*Math.cos(z*0.29)*0.9 + Math.sin(x*0.13+2.1)*Math.cos(z*0.17-1.3)*1.6 + Math.sin((x+z)*0.07)*2.2;
}
```
Scale displacement 0.5-1.0. Then computeVertexNormals with flat shading — for flat shading, we should not use indexed geometry with smooth normals. PlaneGeometry is indexed. flatShading in material uses derivatives in the shader (dFdx of position), so it works regardless of normals. Yes — `flatShading: true` computes normals via derivatives in the fragment shader. So fine.

Actually with flatShading and vertex colors it's fine too.

Ground color: dark green-brown, e.g. 0x1d2a1c with slight variation via vertex colors. Let's add vertex colors based on height for subtle variation. Keep it simple: use vertex colors mixing 0x24331f and 0x1a1f18.

Hmm, dark. Under the warm point light near the fire it'll look good.

Ground material: MeshStandardMaterial({vertexColors:true, flatShading:true, roughness:1, metalness:0}). receiveShadow = true.

Wait — shadows from a point light on a big plane are fine.

Rocks and logs: place around the fire at radius 1.6-4.5, random.

Let me structure:

```js
const rng = mulberry32(1337); // deterministic
```
Use a seeded RNG so layout is consistent.

Trees: for i in 0..45: angle random, radius between 12 and 60, but also ensure distance from center > 11. Height 5-13.

Actually "at least 30 low-poly trees placed randomly around the clearing (not on the fire)" — put 60 trees, radius from 11 to 65.

Tree geometry builder:
- trunk: CylinderGeometry(0.13*s, 0.2*s, trunkH, 5) translated up.
- 3 cones stacked: ConeGeometry(r, h, 6 or 7) at various heights, decreasing radius.

Apply per-tree scale and rotation and position, then applyMatrix4 to the geometry, colorize, push.

Since each tree is unique geometry, merging 60 trees * 4 parts = 240 geometries merged into one. That's fine — maybe 240 * (say 60 verts) = ~15k verts. Cheap.

Wait — for merging, all geometries must have the same attributes. ConeGeometry and CylinderGeometry both have position, normal, uv. Adding color to all. Good. But ConeGeometry/CylinderGeometry are indexed; mergeGeometries handles indexed if all are indexed. Yes all indexed.

Also, do I want to merge? It's fine either way. Merging reduces draw calls dramatically and shadow cost. Let's merge.

Similarly merge rocks into one mesh, logs (the decorative ones on the ground) into one mesh.

Fire stones ring: small dodecahedrons — merge with rocks or separate. Merge into rock mesh.

Fire logs (burning): 4-5 cylinders arranged teepee style, dark charred color with emissive? Keep them dark brown, and the flames above.

Flames: group of meshes. Create a flame shape: use ConeGeometry(0.35, 1.2, 6) with vertices jittered, plus scale animation. Or use a custom "teardrop". Let's use ConeGeometry with radialSegments 6, heightSegments 2, and jitter x/z of vertices for organic look.

Flame material: MeshBasicMaterial({color, transparent:true, opacity, blending: AdditiveBlending, depthWrite:false, side: DoubleSide}).

Create 6 flames with varying colors (0xff5a00, 0xff8c1a, 0xffc14d, 0xffdd88) and sizes; animate y scale by sin with per-flame phase, plus random jitter, and rotate.

Better flame look: layered cones, larger outer orange with lower opacity, smaller inner yellow-white.

Let's define flame objects: {mesh, baseScale, phase, speed, offset}.

Animation: 
```js
const t = time;
for each f:
  const s = 0.75 + 0.35*Math.sin(t*f.speed + f.phase) + 0.15*Math.sin(t*f.speed*2.7 + f.phase*1.7);
  f.mesh.scale.set(f.sx*(0.85+0.2*Math.sin(t*3+f.phase)), f.sy*s, f.sz*(0.85+0.2*Math.cos(t*2.7+f.phase)));
  f.mesh.position.y = f.baseY + 0.06*Math.sin(t*4+f.phase);
  f.mesh.rotation.y = t*0.6 + f.phase;
  f.mesh.material.opacity = f.baseOpacity * (0.7 + 0.3*Math.sin(t*f.speed*1.8+f.phase));
```

Fine.

Point light: color 0xff8a3d, intensity ~ 60? With physically correct lighting in r170, PointLight intensity uses candela and decay 2. distance ~ 30. Let's set intensity high like 25 and distance 40, decay 2. Hmm. In three r155+, lights use physical units by default (useLegacyLights removed in r165). For a point light at ~2 units above ground illuminating ground at 2-4 units distance: illuminance = intensity / d². For d=3, intensity 30 → 3.3. That's bright. Let's use intensity ~ 22 with decay 2. Tune: aim for the ground near fire visibly warm. I'll set base intensity 26 and flicker ±30%.

Also add a very dim hemisphere/ambient light for visibility: AmbientLight 0x2a3a5a intensity 0.35? For night, keep it low so it's moody but trees are visible. Let's do ambient 0x223355 intensity 0.5 and a moon directional light 0x8fb0ff intensity 0.35 from the moon direction. Hmm, directional with shadows off. That helps trees be visible.

Let's be careful: too bright = looks like day. I'll use ambient 0.35, moon directional 0.5 with cool blue.

Since renderer tone mapping ACESFilmic with exposure 1.0, colors get compressed. Let's use exposure 1.2.

Shadows: pointLight.castShadow = true; shadow.mapSize 1024; shadow.camera.near 0.5, far 45; shadow.bias -0.005; shadow.radius maybe.

Note: point light shadow with 1024 cube = 6 renders of 1024². With merged meshes it's ~6 draw calls per face. Fine.

Sparks: 
```js
const SPARKS = 260;
positions Float32Array(SPARKS*3), velocities, life, maxLife
```
Spawn at fire position with small radius, velocity up 0.8-2.4 with drift. Gravity slight negative? Sparks rise then fade. Apply upward velocity and slight damping; add sin drift.

Particle material: PointsMaterial({size:0.06, map: sparkTexture, transparent:true, blending:Additive, depthWrite:false, vertexColors:true, sizeAttenuation:true}).

Give each spark a color from orange→yellow. Use color attribute; fade by setting color toward black as life decreases — but with additive blending, black = invisible. So fade color to black. 

Also need alpha? With additive and black color it fades out naturally. Good, use vertexColors and set color scaled by life ratio.

Texture: canvas radial gradient.

Stars: create with Points, 1600 stars on a sphere of radius 380 (inside sky sphere radius 400). Random direction with y>-0.1 maybe (avoid below horizon; also ground blocks). Actually stars below horizon would be hidden by ground anyway. Let's restrict to upper hemisphere-ish (y > -0.05).

Sizes: use a single PointsMaterial with size 2.2, sizeAttenuation false, map circular texture, transparent, depthWrite false, fog false. Vary brightness via vertex colors.

Twinkle: animate material opacity slightly? Or update color attribute — 1600 updates per frame is fine but let's just do a subtle global opacity oscillation. Actually simple twinkle: modulate points material opacity sin. Meh. Could do per-star twinkle by updating size... skip; light global twinkle.

Hmm, but with sizeAttenuation false and size 2.2 px, on high DPI it stays 2.2 CSS px... Actually renderer sets pixel ratio, gl_PointSize in device pixels. Whatever, small dots fine.

Moon: sphere radius 9 at position (-120, 90, -180) — normalize direction, diameter 18 at distance ~240 → angular size ~4°. Fine.

Moon glow sprite: canvas radial gradient white-blue, size 90. Additive.

Also add a subtle moonlight directional from moon direction.

Placement of moon: put it in a visible direction. Camera at (9,5.5,11) looking at origin → looking toward -x,-z. So put moon at (-140, 70, -160) roughly that direction, elevation atan(70/210) ≈ 18°. Good, visible in the sky above trees.

Fog will fade trees at 95 distance; trees at radius up to 65 → they'll be heavily fogged. Good.

Let me set fog near 25, far 110 — trees at 30-65 partially fogged.

Hmm, "fog that fades the distant trees" — good.

Now OrbitControls: 
```js
controls.target.set(0, 1.6, 0);
controls.minDistance = 4;
controls.maxDistance = 45;
controls.maxPolarAngle = Math.PI * 0.495; // keeps above ground
controls.minPolarAngle = 0.15;
controls.enablePan = false;
controls.enableDamping = true; dampingFactor 0.06;
controls.autoRotate? maybe slow auto-rotate initially? Could be nice for a demo screenshot but might be disorienting. Let's add slow autoRotate = true, speed 0.35, and disable on user interaction? Actually autoRotate continues until user drags... OrbitControls autoRotate still works while damping. Let's enable autoRotate with speed 0.4 and turn it off on 'start' event to hand control to user. Nice touch: the scene slowly rotates in demo mode.

Hmm, autoRotate rotates camera around target which is fine. But with the fire lit and shadows, rotation is nice. I'll do it: autoRotate = true, on controls 'start' → autoRotate = false.

Also need camera.position.y > 0.6 clamp — maxPolarAngle 0.495π with target y=1.6 and distance up to 45 gives y = 1.6 + 45*cos(0.495π) ≈ 1.6 + 45*0.0157 = 2.3. OK above ground. With distance 4 and polar 0.495π: y = 1.6+0.06 = 1.66. Fine. But also clamp in animate: camera.position.y = Math.max(camera.position.y, 0.8).

Also the camera can go under the displaced terrain far away... ground noise up to ~2 units. Meh, clamp y to 1.5 maybe. But at max polar near horizontal the camera is nearly at target height 1.6. Fine. Set clamp min y = 1.2.

Now, flame vertex jitter: after creating ConeGeometry, modify position attribute.

```js
function makeFlameGeo(r, h, seg){
  const g = new THREE.ConeGeometry(r, h, seg, 3, false);
  const p = g.attributes.position;
  for(let i=0;i<p.count;i++){
    const y = p.getY(i);
    const k = 1 - (y + h/2)/h; // 0 at bottom, 1 at top... 
    p.setX(i, p.getX(i) + (Math.random()-0.5)*r*0.45);
    p.setZ(i, p.getZ(i) + (Math.random()-0.5)*r*0.45);
    p.setY(i, y + (Math.random()-0.5)*h*0.08);
  }
  g.computeVertexNormals();
  return g;
}
```
Cone geometry: apex at +h/2, base at -h/2. Base circle at y=-h/2.

Set flame group so base is at y≈0.15.

Also the flames should be positioned above the log teepee, base at y ~0.4.

Let's define fire at origin, ground at y≈0 (center flat, y=0 at center).

Fire pit stones ring radius 1.15, small dodecahedrons size 0.18-0.3.

Burning logs: 5 cylinders radius 0.11, length 1.5, tilted to form a teepee: position at radius 0.45 around, rotate so they lean inward. Simpler: for i in 0..4, angle = i*2π/5, position (cos*0.5, 0.55, sin*0.5), rotate around axis... Easier: create cylinder with geometry translated so it points up (default cylinder is along y), then apply rotation to tilt it toward center by ~40°, then rotate around Y by angle, then position.

Let's do:
```js
const logGeo = new THREE.CylinderGeometry(0.1,0.13,1.6,6);
logGeo.translate(0, 0.8, 0); // base at origin
const m = new THREE.Matrix4();
for i: 
  const g = logGeo.clone();
  g.applyMatrix4(new THREE.Matrix4().makeRotationX(0.55)); // tilt
  g.applyMatrix4(new THREE.Matrix4().makeRotationY(angle));
  g.translate(cos(angle)*0.15? ...)
```
Hmm. Tilt around X then rotate around Y gives a leaning log pointing in some direction. The top leans toward -z? RotationX(0.55) rotates the up vector toward +z... rotX positive: y→z. So top goes toward +z. Then rotY(angle) spreads. Place base at radius 0.55 in the direction opposite the lean so the top converges at the center. Direction of lean after rotY(angle): (sin(angle)*sinθ... ) let's just place the base at (sin? ) Let me compute: after rotX(0.55), the top direction is (0, cos0.55, sin0.55). After rotY(a): x' = z*sin(a) = sin0.55*sin(a), z' = z*cos(a) = sin0.55*cos(a). So top leans toward (sin a, ·, cos a). So base should be at -(sin a, 0, cos a)*0.5 → base at (-0.5 sin a, 0, -0.5 cos a). Then the top is at base + 1.6*(sin0.55 sin a, cos0.55, sin0.55 cos a) = (-0.5+0.83)*... = (0.33 sin a, 1.36, 0.33 cos a). So tops converge roughly around center at height 1.36. Good enough — a teepee. Use tilt 0.6 and base radius 0.45 → tops at 0.35 from center.

Charred logs material: color 0x2a1a12 with emissive 0x3a1400 low.

Then flames inside, from y 0.2 to ~2.2.

OK.

Now also add some ember glow at the base: a small disc/circle with additive orange under the logs? Could add a MeshBasicMaterial circle lying flat with radial-gradient canvas texture, additive blending. Nice subtle. Let's add.

Let's write the code now.

```js
// ---------- utils
function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return ((t^t>>>14)>>>0)/4294967296;}}
const rand = mulberry32(20240613);
const rnd = (a,b)=>a+(b-a)*rand();
```

Textures:

```js
function radialTexture(inner, outer, size=128){
  const c = document.createElement('canvas'); c.width=c.height=size;
  const ctx = c.getContext('2d');
  const g = ctx.createRadialGradient(size/2,size/2,0,size/2,size/2,size/2);
  g.addColorStop(0, inner); g.addColorStop(0.35, outer); g.addColorStop(1,'rgba(0,0,0,0)');
  ctx.fillStyle=g; ctx.fillRect(0,0,size,size);
  const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t;
}
```
For sparks: inner 'rgba(255,255,255,1)', outer 'rgba(255,180,80,0.7)'. Actually the spark color comes from vertex colors; texture should be white-ish so vertex color multiplies. Use inner white, mid white with alpha... Use 'rgba(255,255,255,1)' then 'rgba(255,255,255,0.55)'.

Set texture premultiplied? fine.

Moon glow: inner 'rgba(210,230,255,0.9)', outer 'rgba(120,170,255,0.25)'.

Now let's write the whole thing.

```js
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.15;
document.body.appendChild(renderer.domElement);
```

Camera: fov 55, near 0.1, far 800. position (10, 6, 12).

Scene fog: new THREE.Fog(0x121c2e, 26, 105).

Hmm the sky gradient horizon should be ~0x121c2e.

Sky sphere radius 500, but camera far = 800, ok. Stars at 380 inside. Moon at 260.

Wait, if sky sphere radius is 500 and far is 800, fine. But fog affecting the sky sphere? Set fog:false in shader material (default ShaderMaterial has no fog).

FogExp2 vs Fog: use Fog linear.

Now the ground:

```js
const groundGeo = new THREE.PlaneGeometry(240,240,64,64);
groundGeo.rotateX(-Math.PI/2);
const pos = groundGeo.attributes.position;
const colors = new Float32Array(pos.count*3);
const cA = new THREE.Color(0x2b3a24), cB = new THREE.Color(0x1a2418), cC = new THREE.Color(0x33402a);
for (let i=0;i<pos.count;i++){
  const x = pos.getX(i), z = pos.getZ(i);
  const d = Math.hypot(x,z);
  const h = noise(x,z);
  const flat = THREE.MathUtils.smoothstep(d, 3.0, 16.0); // 0 near center
  const y = h*flat*1.1;
  pos.setY(i, y);
  ...
}
groundGeo.computeVertexNormals();
```

noise function returning roughly -1..1.

```js
function terrain(x,z){
  return Math.sin(x*0.13)*Math.cos(z*0.11)*0.9
       + Math.sin(x*0.05+1.7)*Math.cos(z*0.07-0.6)*1.6
       + Math.sin((x+z)*0.021+0.4)*2.2;
}
```
Range ~ ±4.7. Multiplied by flat and 0.55 → ±2.5 far away. Ridge-ish. Fine but maybe too hilly near trees. Reduce: factor 0.35 → ±1.6. OK.

Also the tree base y should sit on the terrain height. Compute terrain height at tree position with the same formula (with flat factor). Since flat→1 for d>16, and trees are at d>=11... let's compute function:

```js
function groundHeight(x,z){
  const d = Math.hypot(x,z);
  const flat = THREE.MathUtils.smoothstep(d, 3, 16);
  return terrain(x,z)*flat*0.35;
}
```
Trees at radius >= 12, so flat maybe 0.7-1. Sink trunks 0.3 into the ground to avoid floating.

Actually THREE.MathUtils.smoothstep(x, min, max) — signature is smoothstep(x, min, max). Yes.

Colors: mix based on height and random.

```js
const t = THREE.MathUtils.clamp((y+1)/3, 0, 1);
const col = cB.clone().lerp(cA, t).lerp(cC, rand()*0.25);
```

Hmm careful: lerp with random per vertex creates noisy colors. That's fine for low-poly (flat shading averages? no, flat shading uses face colors interpolated). Actually with flatShading, the color still interpolates across the face (colors are vertex attributes interpolated normally). So random per-vertex gives smooth noise gradient. It's fine, subtle.

Let's keep it simple.

Ground material: MeshStandardMaterial({vertexColors:true, flatShading:true, roughness:0.95, metalness:0.0}). receiveShadow = true.

Note: with flatShading and a plane with 64x64 segments at 240 units → 3.75 units per quad. Low-poly look. Good.

Rocks: create ~14 rocks around. Merge. Each rock: IcosahedronGeometry(r, 0) with jittered vertices, scaled non-uniformly, rotated, positioned.

Sizes 0.25-0.9. Position radius 2.5-9.

Also 3-4 bigger boulders at radius 6-14.

Logs on ground (fallen logs): 3 cylinders lying, radius 4-8, rotated randomly around Y, length 3-5, radius 0.25-0.35. With flat shading. Merge into a "wood" mesh. Give them a mossy top? Keep simple.

Tree building:

```js
const treeGeos = [];
function addTree(x,z){
  const s = rnd(0.8,1.9);
  const h = rnd(4.5,9)*... 
}
```

Let me define:
- trunkH = 1.6*s, trunkR = 0.16*s
- foliage cones: 3 layers starting at y=1.0*s with heights 2.2*s, 1.8*s, 1.4*s and radii 1.5*s, 1.2*s, 0.85*s.

Cone with radialSegments 6, heightSegments 1.

Positions: layer i baseY = 0.9*s + i*1.15*s. Cone geometry centered, so translate to baseY + h/2.

Colors: dark greens: 0x1d3a22, 0x24462a, 0x162e1a with per-tree variation. Trunk 0x2c2118.

Total height ≈ 0.9*s + 3*1.15*s ≈ 4.35*s → with s up to 1.9, ~8 units. Good.

Apply per-tree rotation around Y and slight tilt? Skip tilt.

Then translate all to (x, groundHeight, z).

Colorize each part then merge.

That's 60 trees × 4 = 240 geometries. Merging 240 geometries — mergeGeometries is O(n) fine.

But careful: mergeGeometries requires all geometries have identical attribute sets. ConeGeometry & CylinderGeometry: position, normal, uv. Plus my color. Same. Good.

Then set tree mesh castShadow = true, receiveShadow = true.

Hmm, tree mesh casting shadow from point light with shadow camera far 45: trees at radius > 45 will be outside. Fine.

Let me reconsider shadows: point light shadow far 45 covers the clearing. Good.

Fire pit stones: also merge into rock geometry list.

Now flame creation.

Let's also add a "fire glow" sprite? The point light handles that.

Ember disc: CircleGeometry(1.4) rotated flat at y=0.03, with radial gradient texture, additive, opacity 0.5, color orange. Actually a lit ground under the fire is produced by the point light already. Skip? A subtle glow helps the `additive` look. I'll skip to avoid z-fighting.

Let's write sparks.

```js
const SPARK_COUNT = 300;
const sparkPos = new Float32Array(SPARK_COUNT*3);
const sparkCol = new Float32Array(SPARK_COUNT*3);
const sparkVel = new Float32Array(SPARK_COUNT*3);
const sparkLife = new Float32Array(SPARK_COUNT);
const sparkMax = new Float32Array(SPARK_COUNT);
```

Init: spawn all with random life so it's populated immediately.

```js
function spawnSpark(i, initial){
  const a = rand()*Math.PI*2, r = Math.random()*0.55;
  sparkPos[i*3] = Math.cos(a)*r;
  sparkPos[i*3+1] = rnd(0.2, 1.0);
  sparkPos[i*3+2] = Math.sin(a)*r;
  sparkVel[i*3] = (rand()-0.5)*0.5;
  sparkVel[i*3+1] = rnd(0.9, 2.6);
  sparkVel[i*3+2] = (rand()-0.5)*0.5;
  sparkMax[i] = rnd(1.0, 2.6);
  sparkLife[i] = initial ? rand()*sparkMax[i] : 0;
}
```

Update each frame:
```js
for i:
  sparkLife[i] += dt;
  if (sparkLife[i] > sparkMax[i]) spawnSpark(i, false);
  const t = sparkLife[i]/sparkMax[i];
  // buoyancy: velocity up decreases? sparks rise and swirl
  sparkVel[i*3+1] += 0.6*dt; // accelerate up? Or apply drag
  ...
```
Let's do: velocity.y decays toward 0.15 with drag, plus horizontal swirl using sin.

Simpler:
```js
p.y += v.y*dt;
p.x += (v.x + Math.sin(time*3 + i)*0.35)*dt;
p.z += (v.z + Math.cos(time*2.6 + i*1.3)*0.35)*dt;
v.y *= 0.995;  // slow
```
Hmm, per-frame damping isn't dt independent. Use v.y -= 0.4*dt (slow down).

Actually sparks should rise quickly then slow. Add gravity-like: v.y += -0.5*dt + initial upward. Whatever, keep it simple and visually fine.

Color: from bright yellow-white at t=0 to deep orange/red then black:
```js
const f = 1 - t; // fade
col = base color (1.0, 0.55+0.35*(1-t), 0.12) * (f*f)
```
Let's do: r = 1.0*f, g = (0.6*(1-t*0.8))*f, b = 0.15*f*f. Hmm.

Let's do simpler: 
```js
const b = Math.pow(1 - t, 1.6);
cr = 1.0*b; cg = (0.75 - 0.45*t)*b; cb = (0.25 - 0.2*t)*b;
```
At t=0: (1, 0.75, 0.25) bright amber. At t=1: 0. Good.

Note: with additive blending and sRGB output + ACES tonemapping, colors will look decent.

Also need geometry.setDrawRange? No, all particles always drawn.

Attribute updates: needsUpdate = true each frame.

PointsMaterial with vertexColors and map and additive.

Size: 0.09. With sizeAttenuation true.

Now flame flicker:

```js
const light = new THREE.PointLight(0xff7b2e, 24, 40, 2);
light.position.set(0, 1.5, 0);
light.castShadow = true;
light.shadow.mapSize.set(1024,1024);
light.shadow.camera.near = 0.3;
light.shadow.camera.far = 40;
light.shadow.bias = -0.004;
light.shadow.radius = 2;
```
Point light shadow radius only works for PCF? radius works with PCFSoftShadowMap? shadow.radius works with PCFShadowMap / PCFSoft? It's used in VSM mostly. Ignore.

Flicker: 
```js
const t = time;
const flick = 0.78 + 0.22*Math.sin(t*11.3) * Math.sin(t*7.7+1.2) + 0.1*Math.sin(t*23.1+0.5);
light.intensity = 26 * THREE.MathUtils.clamp(flick, 0.55, 1.25);
light.position.set(Math.sin(t*3.4)*0.12, 1.45 + Math.sin(t*5.1)*0.08, Math.cos(t*2.9)*0.12);
```
Careful: moving the shadow-casting point light every frame re-renders the shadow map — that's already happening each frame anyway. Fine.

Also add an AmbientLight and a moon DirectionalLight.

Directional moon light: position from moon direction * 100, intensity 0.6, color 0x9db8ff. castShadow false.

Ambient: 0x1a2740, intensity 0.6? With tone mapping. Let's try ambient intensity 0.45 color 0x2a3b5c. Hmm ambient color multiplies... ambient light of color 0x2a3b5c intensity 0.45 gives dark blue fill. Trees will look dark blue-ish. Good for night.

Also a HemisphereLight (sky 0x223355, ground 0x0a0f0a, 0.5) might be nicer. Let's use hemisphere instead of ambient... use both lightly.

I'll use: HemisphereLight(0x2c4470, 0x080a06, 0.9) and DirectionalLight moon 0.5.

Hmm, need enough to see the trees. Let's tune roughly and trust.

Now animation loop with clock.

```js
let last = performance.now();
function animate(now){
  requestAnimationFrame(animate);
  const dt = Math.min((now-last)/1000, 0.05); last = now;
  time += dt;
  ...
  controls.update();
  // clamp camera
  if (camera.position.y < 1.2) camera.position.y = 1.2;
  renderer.render(scene, camera);
}
```

Note: clamping camera y after controls.update may fight with OrbitControls; but since maxPolarAngle prevents it, fine.

requestAnimationFrame callback gets timestamp. First call: pass. Fine.

Resize:
```js
window.addEventListener('resize', ()=>{
  camera.aspect = window.innerWidth/window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.setPixelRatio(Math.min(devicePixelRatio,2));
});
```

Also hide hint after 6 seconds: setTimeout(()=> hint.style.opacity = 0, 6000).

Hmm, the screenshot at 3s will include the hint. That's fine and informative.

Actually — the requirement mentions the iframe only receives keyboard events after click. OrbitControls uses pointer events, which work. I'll add the hint text "Drag to look around". Good.

Let me now double check `smoothstep` usage: THREE.MathUtils.smoothstep(x, min, max) returns 0 when x<=min, 1 when x>=max. Yes.

Now write the actual code carefully.

One thing: mergeGeometries import path 'three/addons/utils/BufferGeometryUtils.js' exports `mergeGeometries` in r170. Yes.

Let me write addTree using Matrix4 composition:

```js
function prepGeo(geo, color){
  const c = new THREE.Color(color);
  const n = geo.attributes.position.count;
  const arr = new Float32Array(n*3);
  for (let i=0;i<n;i++){ arr[i*3]=c.r; arr[i*3+1]=c.g; arr[i*3+2]=c.b; }
  geo.setAttribute('color', new THREE.BufferAttribute(arr,3));
  return geo;
}
```

Tree:
```js
const treeParts = [];
const dummyM = new THREE.Matrix4();
function addTree(x, z, scale){
  const y = groundHeight(x,z) - 0.4;
  const parts = [];
  const trunkH = 1.7*scale;
  const trunk = new THREE.CylinderGeometry(0.13*scale, 0.22*scale, trunkH, 5, 1);
  trunk.translate(0, trunkH/2, 0);
  parts.push(prepGeo(trunk, TRUNK_COLORS[...]));
  const layers = [
    {r:1.55, h:2.3, y:0.85},
    {r:1.20, h:1.95, y:1.95},
    {r:0.85, h:1.6, y:3.0},
  ];
  layers.forEach((L,i)=>{
    const g = new THREE.ConeGeometry(L.r*scale, L.h*scale, 6, 1);
    g.translate(0, (L.y + L.h/2)*scale, 0);
    parts.push(prepGeo(g, greenColor));
  });
  const m = new THREE.Matrix4().makeRotationY(rand()*Math.PI*2);
  m.setPosition(x, y, z);
  parts.forEach(g=>{ g.applyMatrix4(m); treeParts.push(g); });
}
```

Wait — the trunk rotation doesn't matter; the cone rotation matters for variety. OK.

Heights: at scale 1.9, top ≈ (3.0+1.6)*1.9 = 8.7. Good.

Green colors: pick from array with slight random: 
```js
const greens = [0x1f4026, 0x274d2b, 0x18341f, 0x2c5433];
const green = greens[Math.floor(rand()*greens.length)];
```

Now the total geometry count: 60 trees * 4 = 240. mergeGeometries might be a bit slow but fine (<100ms).

Rocks:
```js
const rockParts = [];
function addRock(x,z,r,squash){
  const g = new THREE.IcosahedronGeometry(r, 0);
  const p = g.attributes.position;
  for (let i=0;i<p.count;i++){
    p.setXYZ(i, p.getX(i)*(0.75+rand()*0.5), p.getY(i)*(0.55+rand()*0.4), p.getZ(i)*(0.75+rand()*0.5));
  }
  g.computeVertexNormals();
  ...
}
```
Hmm, jittering each vertex independently of shared vertices — IcosahedronGeometry is non-indexed (PolyhedronGeometry produces non-indexed), so vertices are duplicated → jittering breaks the shape apart, creating cracks. For low-poly rocks that can actually look broken. Better: scale all vertices non-uniformly (uniform per-vertex noise not needed) — just apply a random scale and rotation to the whole icosahedron. That gives clean low-poly rocks. Let's do that plus random rotation.

So: rock = IcosahedronGeometry(r,0), scale (sx,sy,sz) random 0.7-1.3, rotate. Good, keeps it convex-ish.

Actually FlatShading with icosahedron detail 0 gives 20 faces — nice low-poly rock.

Ground stones ring: 9 stones radius 0.22 around r=1.25.

Logs (fallen): cylinders.

Now the fire pit: ring of stones at radius 1.3, plus maybe a darker circle? Just stones.

Let's write it all out.

Also add a few grass tufts? Skip.

One more: to make the clearing read as a clearing, trees start at radius 11. Good.

Let's also double-check that the ground plane 240 wide with 64 segments goes to ±120, and fog far 105. Good.

Now write the sky gradient shader material.

```js
const skyGeo = new THREE.SphereGeometry(500, 32, 16);
const skyMat = new THREE.ShaderMaterial({
  side: THREE.BackSide, depthWrite: false, fog: false,
  uniforms: { topColor:{value:new THREE.Color(0x01030a)}, bottomColor:{value:new THREE.Color(0x16233c)}, offset:{value:0.0}, exponent:{value:0.9} },
  vertexShader: `varying vec3 vPos; void main(){ vPos = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
  fragmentShader: `uniform vec3 topColor; uniform vec3 bottomColor; varying vec3 vPos; void main(){ float h = normalize(vPos).y; float t = pow(smoothstep(-0.25, 0.75, h), 0.8); gl_FragColor = vec4(mix(bottomColor, topColor, t), 1.0); }`
});
```
Note colors go through tone mapping? ShaderMaterial output is not tone-mapped automatically (toneMapped default true but tone mapping is applied in the fragment shader chunk for built-in materials). For raw ShaderMaterial, no tonemapping applied → colors appear as-is in sRGB? Actually the renderer applies output color space conversion in the shader chunk `colorspace_fragment` which is only included in materials that include it. Raw ShaderMaterial without those chunks outputs linear values directly to the sRGB framebuffer... Hmm, in WebGL2 with outputColorSpace SRGB, three uses a real sRGB framebuffer? No — three renders to a linear framebuffer and applies encoding in shader (unless using WebGLRenderTarget). Actually since r152, the canvas is set with `colorSpace` and three does the conversion in the shader via `colorspace_fragment` include. For custom ShaderMaterial, you need `#include <colorspace_fragment>` yourself.

To avoid getting dark/incorrect sky colors, I'll add the include:

fragmentShader:
```glsl
uniform vec3 topColor; uniform vec3 bottomColor; varying vec3 vPos;
void main(){
  float h = normalize(vPos).y;
  float t = pow(smoothstep(-0.3, 0.8, h), 0.75);
  vec3 c = mix(bottomColor, topColor, t);
  gl_FragColor = vec4(c, 1.0);
  #include <colorspace_fragment>
}
```
And since I define colors with THREE.Color which converts hex sRGB → linear automatically? THREE.Color(0x16233c) with default color management — `new THREE.Color(hex)` treats hex as sRGB and converts to linear working space when ColorManagement.enabled (default true). Yes. So uniform value is linear, and colorspace_fragment converts back to sRGB. 

Also `#include <tonemapping_fragment>` before colorspace if I want consistency. Let's include both, tone mapping applied. Actually with ACES, the dark sky would get even darker/lifted. Let's include tonemapping too for consistency? Hmm. ACES on dark blue → slightly desaturated dark blue. Fine either way. I'll include both to keep the sky consistent with fog color on distant trees... but fog color is applied in linear then tonemapped+encoded for standard materials. If the sky skips tonemapping, the horizon might not match the fogged trees. Including tonemapping makes them consistent. So include both chunks.

Order: gl_FragColor = ...; then #include <tonemapping_fragment> then #include <colorspace_fragment>. Yes.

Hmm but tonemapping_fragment uses `toneMapping` defines that are set by the renderer only for materials with `toneMapped: true`. Actually the renderer injects TONE_MAPPING define and the function for all materials when renderer.toneMapping !== NoToneMapping... Let me recall: WebGLProgram adds `getToneMappingFunction` in the prefix when `parameters.toneMapping !== NoToneMapping`, which is determined by `material.toneMapped && renderer.toneMapping !== NoToneMapping`. So for ShaderMaterial with toneMapped: true (default), the define is added. Good. And for the include to work, the chunk must exist — it does.

Also fog: I want the sky NOT fogged (it's beyond fog far anyway, would just be fog color). Since ShaderMaterial has no fog by default, fine.

Similarly, the stars/moon/moon-glow need fog:false. PointsMaterial has a `fog` property (default true). Set fog:false. MeshBasicMaterial same.

Now the stars: they're at radius 380, inside the sky sphere (500). The sky sphere is drawn... depthWrite false and it's rendered first? Render order: opaque objects sorted front to back, but sky is at 500 far so drawn... With depthWrite:false and depthTest default true, the sky writes no depth, but it does test against depth. Since it's rendered in the opaque pass sorted by distance (front-to-back by centroid), the sky sphere centroid is at origin → distance 0? Actually sorting uses z-depth of the object's world position projected. The sphere's center is at camera-ish. Hmm, this could cause the sky to render before other things which is fine (no depth write, but it writes color). Then mountains/trees drawn after will cover it. Good. But if the sky renders after some objects with depthWrite false and depthTest true, it would be occluded correctly by depth. Either way fine.

Actually risk: sky at radius 500 with camera far = 800 — fine.

Stars at 380 with camera far 800 fine.

But wait: sky sphere is centered at origin, not at the camera. Camera at distance ~15 from origin → sky sphere edge at 485-515. Fine.

Stars: they should not be occluded by the sky sphere (radius 500 > 380). Good.

Moon at distance ~260 → inside sky. Good.

Moon glow sprite: Sprite with sizeAttenuation? Sprite scale in world units: set scale 3x moon diameter... Sprite material with map, additive, depthWrite false, fog false. Sprite scale set to (70,70,1) at distance 260 → looks 15° across. Moon sphere radius 9 → diameter 18 at 260 ≈ 4°. Glow 70 ≈ 15°. Good.

Sprite scale: sprite.scale.set(70,70,1).

Moon color: 0xdfe8ff basic material. Add slight emissive - MeshBasicMaterial is unlit so color is flat. Good. Add toneMapped true → ACES will dim it. Set material.toneMapped = false for the moon so it stays bright white? Then colorspace convert still happens. Let's set toneMapped false for moon and stars so they're crisp. Hmm, but then uniform colors converted sRGB→linear→sRGB output = same. Fine.

Actually for PointsMaterial with vertexColors and toneMapped false, round-trip is fine.

Let's keep it simple: moon toneMapped false.

Now writing everything.

Let me also handle the flame material transparency sorting: multiple additive transparent flames — set depthWrite false and renderOrder maybe. With AdditiveBlending and depthWrite false, order doesn't matter much visually.

The flames are inside the log teepee; the logs are opaque and will occlude the flames behind them. Good.

Now, the flame geometry base: cone with base at -h/2. I'll translate the geometry up by h/2 so base at y=0, then position the flame mesh at y=0.15.

Let's create flames:

```js
const flames = [];
const flameColors = [0xff3d00, 0xff6a00, 0xff9500, 0xffc247, 0xffe08a];
const flameSpecs = [
  {r:0.62, h:1.9, y:0.10, c:0xff3a00, op:0.55, x:0, z:0},
  {r:0.46, h:1.55, y:0.12, c:0xff6a10, op:0.7, x:0.12, z:0.1},
  ...
];
```

I'll generate 6 procedurally:
```js
for (let i=0;i<6;i++){
  const r = 0.55 - i*0.06;
  const h = 1.9 - i*0.18;
  ...
}
```
Simpler explicit list of 6 with offsets.

Let me define:
```js
const flameDefs = [
  { x: 0.00, z: 0.00, r: 0.62, h: 2.00, y: 0.05, color: 0xff4400, opacity: 0.55, sp: 1.7 },
  { x: 0.14, z: -0.10, r: 0.46, h: 1.70, y: 0.10, color: 0xff7800, opacity: 0.62, sp: 2.1 },
  { x: -0.16, z: 0.08, r: 0.40, h: 1.45, y: 0.12, color: 0xffa11a, opacity: 0.6, sp: 2.5 },
  { x: 0.04, z: 0.16, r: 0.30, h: 1.20, y: 0.14, color: 0xffc94d, opacity: 0.6, sp: 3.1 },
  { x: -0.06, z: -0.12, r: 0.20, h: 0.85, y: 0.16, color: 0xffe9a8, opacity: 0.75, sp: 3.6 },
];
```
5 flames. Fine.

Animation per flame:
```js
const p = f.phase;
const flick = 0.85 + 0.22*Math.sin(t*f.sp + p) + 0.12*Math.sin(t*f.sp*2.3 + p*2.0);
f.mesh.scale.set(f.r* (0.9+0.15*Math.sin(t*f.sp*1.7+p)), f.h*flick, f.r*(0.9+0.15*Math.cos(t*f.sp*1.9+p)));
```
Hmm scaling y by flick where flick ~0.85 means the flame grows/shrinks around its base — since geometry base is at y=0 and we scale, the flame stays anchored at the bottom. 

Also rotate y slowly.

And sway: position.x = f.x + Math.sin(t*2.3 + p)*0.06.

Opacity: f.mesh.material.opacity = f.opacity * (0.75 + 0.25*Math.sin(t*f.sp*1.4+p));

Good.

Since each flame has its own material (different color/opacity), 5 materials — fine.

Let me now write out the final code.

Also I should ensure the ground receives shadows and the fire lights it.

One concern: point light shadow with `shadow.camera.far = 40` and the light at y=1.5. Objects near the fire cast shadows outward. Good.

Potential issue: shadow acne on the ground. Use bias -0.003 and normalBias 0.02.

Let's write.

```js
// ============ Setup
const container = document.body;

const renderer = new THREE.WebGLRenderer({ antialias: true });
...
```

Also careful: `document.body` styles set to overflow hidden.

Let me write full code now.

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

// ---------- deterministic RNG
function mulberry32(a) {
  return function () {
    a |= 0; a = (a + 0x6D2B79F5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const rand = mulberry32(9137);
const rnd = (a, b) => a + (b - a) * rand();

// ---------- renderer
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 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);

const scene = new THREE.Scene();
const FOG_COLOR = 0x121c2e;
scene.fog = new THREE.Fog(FOG_COLOR, 26, 108);

const camera = new THREE.PerspectiveCamera(55, window.innerWidth/window.innerHeight, 0.1, 900);
camera.position.set(9.5, 5.2, 11.5);
```

Terrain funcs, ground, etc.

Let me write the ground height function before using it.

```js
function terrainNoise(x, z) {
  return Math.sin(x * 0.13) * Math.cos(z * 0.11) * 1.0
       + Math.sin(x * 0.052 + 1.7) * Math.cos(z * 0.068 - 0.6) * 1.7
       + Math.sin((x + z) * 0.023 + 0.4) * 2.3;
}
function groundHeight(x, z) {
  const d = Math.hypot(x, z);
  const f = THREE.MathUtils.smoothstep(d, 4.5, 20);
  return terrainNoise(x, z) * f * 0.42;
}
```

Ground geometry uses the same.

Now, everything else as planned.

For the ground vertex colors, let's mix:
```js
const gA = new THREE.Color(0x2a3a22); // lighter grass
const gB = new THREE.Color(0x141d12); // dark
const gC = new THREE.Color(0x3a3324); // dirt
```
For each vertex: t = smoothstep(y, -1, 1.5); col = gB.clone().lerp(gA, t); then add dirt near the fire: if d < 8, lerp toward gC by (1 - d/8)*0.5. That creates a dirt clearing. 

Careful: cC dirt is brownish 0x3a3324 - a bit bright. Use 0x2e2a1e.

Let's do it.

Also add slight random variation: col.multiplyScalar(0.9 + rand()*0.2).

Now writing everything out fully.

Final check on `mergeGeometries` — signature `mergeGeometries(geometries, useGroups)`. Returns merged geometry or null. If geometries have differing attributes it returns null with an error. All mine have position, normal, uv, color. Should be fine.

Actually wait: PlaneGeometry etc. not merged. Only cone+cylinder (both have position/normal/uv). Good.

Rock merge: IcosahedronGeometry (non-indexed) — all non-indexed, consistent. Good. If I mix in CylinderGeometry (indexed) for stones... no, stones are icosahedrons too. But the fire log teepee cylinders are indexed — I'll keep them as a separate merged group (indexed). Fine.

Actually mergeGeometries handles mixing indexed and non-indexed? It requires all to be indexed or all non-indexed. Keep separate groups.

Groups:
1. trees (cone + cylinder, all indexed) ✓
2. rocks (icosahedron, non-indexed) ✓
3. fallen logs (cylinder, indexed) ✓
4. fire logs (cylinder, indexed) ✓

Good.

Now writing.

For the fire logs colors: dark charred 0x241611 with some parts orange? Could set emissive... but MeshStandardMaterial with vertexColors merged into a single material — emissive applies globally. Let's make the fire logs their own material with a slight emissive 0x140500? Actually just leave dark; the fire light illuminates them.

Hmm, actually the fire logs are inside/behind the flames and lit by the point light which is at y=1.5 above them — they'd get lit from above. Good.

Now the code:

```js
// fire pit logs
const fireLogGeos = [];
const LOG_N = 5;
for (let i = 0; i < LOG_N; i++) {
  const a = (i / LOG_N) * Math.PI * 2 + 0.35;
  const g = new THREE.CylinderGeometry(0.085, 0.115, 1.55, 6, 1);
  g.translate(0, 0.775, 0);
  const m = new THREE.Matrix4().makeRotationX(0.62);
  g.applyMatrix4(m);
  g.applyMatrix4(new THREE.Matrix4().makeRotationY(a));
  g.translate(-0.42 * Math.sin(a), 0.02, -0.42 * Math.cos(a));
  fireLogGeos.push(prepGeo(g, 0x261a12));
}
```

Hmm wait, prepGeo adds color attribute. Then material vertexColors:true.

The translate order: after rotY, the log leans toward direction (sin a, ·, cos a). So base should be at -(sin a, 0, cos a)*0.42. Yes as written.

Top of log: base + 1.55*(sin0.62*sin a, cos0.62, sin0.62*cos a) = (-0.42+1.55*0.581)*sin a = (-0.42+0.90)*sin a = 0.48*sin a. So tops are at radius 0.48 from the center at height 1.55*0.814 = 1.26. Good teepee.

Flames will be inside that, up to y 2. Fine.

Now let's write it all out. I'll be careful with variable naming.

Also `prepGeo` for rocks: rocks are non-indexed so color attribute count = position count. Fine.

Let me write the final HTML.

Additional detail: the hint div. And maybe a title "CAMPFIRE" — nah, keep the hint only.

Let me write the animate loop.

```js
const clock = new THREE.Clock();
let time = 0;

function animate() {
  requestAnimationFrame(animate);
  const dt = Math.min(clock.getDelta(), 0.05);
  time += dt;
  updateFlames(time, dt);
  updateSparks(time, dt);
  updateLight(time);
  controls.update();
  if (camera.position.y < 1.1) camera.position.y = 1.1;
  renderer.render(scene, camera);
}
```

Wait, camera clamp after controls.update — OrbitControls sets position from spherical. If I clamp y then next frame controls recompute from camera position... OrbitControls computes offset = camera.position - target each update, so clamping y persists as a modified spherical. It might cause a slow drift. Since maxPolarAngle prevents it anyway, I'll keep the clamp as a safety net only.

updateSparks needs dt.

Spark spawn function uses `rand()` — deterministic RNG shared, fine.

Let's write updates.

```js
function updateSparks(dt, t) {
  const pos = sparkGeo.attributes.position.array;
  const col = sparkGeo.attributes.color.array;
  for (let i = 0; i < SPARK_COUNT; i++) {
    const i3 = i * 3;
    sparkLife[i] += dt;
    if (sparkLife[i] >= sparkMax[i]) resetSpark(i);
    const lifeT = sparkLife[i] / sparkMax[i];
    // motion
    sparkVel[i3 + 1] -= 0.35 * dt;             // slow rise
    const swirl = Math.sin(t * 2.6 + i * 0.7) * 0.5;
    pos[i3]     += (sparkVel[i3] + swirl * 0.35) * dt;
    pos[i3 + 1] += sparkVel[i3 + 1] * dt;
    pos[i3 + 2] += (sparkVel[i3 + 2] + Math.cos(t * 2.2 + i * 0.9) * 0.35) * dt;
    // fade
    const b = Math.pow(1 - lifeT, 1.7);
    col[i3]     = b;
    col[i3 + 1] = b * (0.72 - 0.42 * lifeT);
    col[i3 + 2] = b * (0.22 - 0.20 * lifeT) * (1 - lifeT);
    ...
  }
}
```
Hmm col[i3+2] with (0.22-0.2*t) can go slightly negative at t=1 → clamp: Math.max(0, ...).

Simplify: 
```js
const b = Math.pow(1 - lifeT, 1.7);
col[i3] = b * 1.0;
col[i3+1] = b * (0.75 - 0.5 * lifeT);
col[i3+2] = b * 0.25 * (1 - lifeT);
```
At t=0: (1, 0.75, 0.25). At t=0.5: b=0.31 → (0.31, 0.155, 0.039). At t=1: 0.

Good.

`pos` and `col` arrays: I'll read directly from geometry attribute arrays. Since I use `sparkPos` as the array reference passed to BufferAttribute, I can just use that variable directly. Let's use `sparkPos`/`sparkCol` Float32Arrays accessible in scope.

Set needsUpdate on both.

Reset spark:
```js
function resetSpark(i) {
  const a = rand() * Math.PI * 2;
  const r = Math.sqrt(rand()) * 0.6;
  const i3 = i * 3;
  sparkPos[i3] = Math.cos(a) * r;
  sparkPos[i3 + 1] = rnd(0.15, 0.9);
  sparkPos[i3 + 2] = Math.sin(a) * r;
  sparkVel[i3] = rnd(-0.35, 0.35);
  sparkVel[i3 + 1] = rnd(1.0, 2.8);
  sparkVel[i3 + 2] = rnd(-0.35, 0.35);
  sparkMax[i] = rnd(1.1, 2.8);
  sparkLife[i] = 0;
}
```
Initial: call resetSpark then set life random for a few.

Now the initial call sets sparkLife=0, so all start at the base — they'd all rise together. Set `sparkLife[i] = rand() * sparkMax[i]` after reset for initial.

Then also handle position offset for those with initial life: the position is at base but life is mid — visually they'd all be near the base. Meh, they'll spread within 2 seconds. Since screenshot at 3s, it's fine. But better: after reset in init, advance the position by life*velocity. Let's just do a warm-up loop: run updateSparks(1/60) 120 times before the first render? That would be expensive-ish but fine (300*120 = 36k iterations, trivial). Actually simpler: after init, do `for (let k=0;k<200;k++) updateSparks(1/60, k/60)`. That gives a natural distribution. Let's do that.

But careful, time must be defined. I'll do the warm-up after defining everything with a local t.

Fine.

Now light flicker function:

```js
function updateLight(t) {
  const flick = 0.82
    + 0.16 * Math.sin(t * 11.3)
    + 0.10 * Math.sin(t * 7.1 + 1.7)
    + 0.07 * Math.sin(t * 23.7 + 0.4);
  fireLight.intensity = FIRE_BASE * flick;
  fireLight.position.set(Math.sin(t * 3.1) * 0.10, 1.45 + Math.sin(t * 4.7) * 0.07, Math.cos(t * 2.7) * 0.10);
  // also modulate the ground glow sprite? skip
}
```
FIRE_BASE = 30 maybe.

Let me think about the point light intensity in three r170: PointLight intensity in candela, illuminance at distance d = I/d². The ground below the fire at distance ~1.5 → 30/2.25 = 13.3. That's very bright (1.0 is roughly "white"). Way too bright. Hmm.

In three.js, a DirectionalLight of intensity 1 gives irradiance 1 → surface color = albedo * 1 * NdotL. For a PointLight with physical falloff, irradiance = intensity/d² * NdotL... Actually three's point light: `irradiance = intensity / distanceSq`? Let's recall `getDistanceAttenuation`: `pow(saturate(-dist/cutoffDistance+1), decayExponent)`... In newer versions with `decay`, attenuation = 1/max(dist^decay, ...). And light intensity is divided by... In WebGLLights, pointLight color = color * intensity, and in the shader: `irradiance = lightColor * getDistanceAttenuation(...)`. Hmm, actually `getPointLightInfo`: `light.color = pointLight.color * getDistanceAttenuation(lightDistance, pointLight.distance, pointLight.decay)`. And in r155+, `pointLight.color = color.multiplyScalar(intensity)` where intensity is scaled... 

Since r155, `useLegacyLights = false` by default: point/spot intensity is in candela, and the shader computes `attenuation = 1/d²` (physically correct). And there's a factor: in WebGLLights, `color.copy(light.color).multiplyScalar(light.intensity)` — and for physically correct lights, the point light intensity gets multiplied by... I recall `intensity * Math.PI * 4`? No that's for legacy conversion.

Let me just empirically reason: A typical three.js example with PointLight(0xffffff, 1, 100) at distance 5 gives a visible but dim result. With intensity 1 and d=5: 1/25 = 0.04 → very dim. So to get a well-lit scene at 3 units, you want intensity ~ 9-30.

Ground near fire at 2-4 units: 30/9 = 3.3, /16=1.9. That's bright but with tone mapping and a dark albedo (0.1), it becomes 0.33 — reasonable warm glow.

Actually albedo of the ground ~ (0x2a3a22) → linear ~0.02-0.04. Very dark. So 3.3 * 0.03 = 0.1 → visible but dim. Hmm. Maybe increase ground brightness or light intensity.

Let's use ground colors a bit brighter, e.g. 0x3b4a2e etc. And FIRE_BASE = 45. Also, ACES tone mapping with exposure 1.15 lifts shadows a bit.

I'll go with FIRE_BASE = 40 and see mentally: at d=3, 40/9 = 4.4 * albedo 0.06 = 0.26 → moderately bright warm. Good.

The rocks and logs near the fire will be nicely lit.

Trees at d=15: 40/225 = 0.18 * albedo 0.05 = 0.009 → nearly black. They'll be lit by the hemisphere light instead, which needs to be enough to see silhouettes. HemisphereLight intensity 0.6 with sky color 0x2c4470 (linear ~0.02-0.06)... The irradiance = skyColor * intensity ≈ 0.6*0.03 = 0.018 times albedo 0.05 = 0.0009. Too dark.

Hmm, night scenes are tricky. Let's set hemisphere intensity to 1.5 and sky color 0x4a6a9e, ground 0x141a12. Then irradiance ~ 1.5*0.1 = 0.15 * albedo 0.05 = 0.0075 → still dark but visible as dark blue shapes. With the tone mapping and sRGB encoding, 0.0075 linear → sRGB ≈ 0.09 → visible dark blue. OK that works.

Actually let's just be more generous: trees get a "moon" directional light of intensity 1.2 with color 0x9dc0ff. Directional irradiance = intensity * color → 1.2 * (0.35,0.53,1.0 in linear-ish) ≈ 0.5. Times albedo 0.05 = 0.025 → sRGB 0.17. That's reasonably visible. Good.

So: DirectionalLight(0x9dc0ff, 1.6) from moon direction, hemisphere (0x35507a, 0x0a0e08, 1.2), and the fire point light.

Also the ground albedo: I set colors like 0x2a3a22 → linear ~ (0.026, 0.045, 0.017). Under moonlight 1.6*0.5=0.8 → 0.02-0.036 → sRGB ~0.15-0.2 → visible dark green. Good.

Let's also make the moon directional cast no shadows.

OK, and exposure 1.1.

I think it'll look decent. Let's finalize.

One more consideration: I want the fire to be the visual focus and clearly visible in the screenshot. The camera is at (9.5, 5.2, 11.5) looking at (0,1.6,0) — distance ~15.8. The fire (2 units tall) will appear smallish. Let's move the camera closer: (6.5, 3.6, 8) → distance ~11. With fov 55, the fire at 11 units subtends... vertical extent at 11 units with fov 55 is 2*11*tan(27.5°) = 11.4 units. Fire 2 units = 17% of screen height. That's OK but let's get closer: (5, 3, 6.5) → distance 8.7, visible height 9 → fire 22%. Good.

Set camera.position.set(5.5, 3.4, 7.2). OrbitControls minDistance 3.5, maxDistance 40.

Then autoRotate will slowly orbit. Good.

But at that distance, trees at radius 11+ will be behind and around — filling the frame with fogged silhouettes. Good.

Camera near origin at height 3.4 looking down at target y=1.6 → the ground in front will be visible.

Alright.

Let's write out everything now, carefully.

Also add a few "background" stars twinkle? Skip.

Write the code:

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Campfire in the Clearing</title>
<style>
  html, body { margin:0; padding:0; height:100%; background:#05070e; overflow:hidden; }
  canvas { display:block; }
  #vignette { position:fixed; inset:0; pointer-events:none;
    background: radial-gradient(ellipse at 50% 60%, rgba(0,0,0,0) 35%, rgba(0,0,0,.62) 100%); }
  #hint { position:fixed; left:50%; bottom:20px; transform:translateX(-50%);
    color:#c8d6ea; font:500 12px/1.4 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
    letter-spacing:.18em; text-transform:uppercase; opacity:.8; pointer-events:none;
    text-shadow:0 1px 14px rgba(0,0,0,.9); transition:opacity 1.2s ease; white-space:nowrap; }
</style>
</head>
<body>
<div id="vignette"></div>
<div id="hint">Drag to look around</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">
...
</script>
</body>
</html>
```

Note: the importmap must come before the module script — yes it does.

Now the module content. Let me write it in order.

I'll write `prepGeo` early since used everywhere.

Full code:

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

/* ---------------- helpers ---------------- */
function mulberry32(a) { ... }
const rand = mulberry32(20240613);
const rnd = (a,b) => a + (b-a)*rand();

function paint(geo, hex) {
  const c = new THREE.Color(hex);
  const n = geo.attributes.position.count;
  const arr = new Float32Array(n * 3);
  for (let i = 0; i < n; i++) { arr[i*3] = c.r; arr[i*3+1] = c.g; arr[i*3+2] = c.b; }
  geo.setAttribute('color', new THREE.BufferAttribute(arr, 3));
  return geo;
}

function radialTexture(stops, size = 128) { ... }
```

radialTexture signature with stops array: [{o:0, c:'rgba(...)'}, ...].

```js
function radialTexture(stops, size) {
  size = size || 128;
  const cv = document.createElement('canvas');
  cv.width = cv.height = size;
  const ctx = cv.getContext('2d');
  const g = ctx.createRadialGradient(size/2, size/2, 0, size/2, size/2, size/2);
  for (const s of stops) g.addColorStop(s.o, s.c);
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, size, size);
  const tex = new THREE.CanvasTexture(cv);
  tex.colorSpace = THREE.SRGBColorSpace;
  return tex;
}
```

Then renderer/scene/camera.

Then sky.

Then lights.

Then ground.

Then terrain-scattered stuff.

Then fire.

Then controls + animate.

Let me now think about the sky sphere + fog interplay again: fog far = 108 and the sky is at 500 — the sky material has no fog, so it's fine.

But: the ground plane extends to ±120, beyond fog far. Its far parts become fully fog colored (0x121c2e) which matches the sky bottom color roughly (0x16233c). Slight mismatch but with the vignette it's fine. Let's set the sky bottom color equal to the fog color: 0x121c2e, and fog color 0x121c2e. Then the horizon blends perfectly. But then the sky just above the horizon is the same as the fog... that's realistic.

Hmm, but the sky gradient goes from bottom color at h=-0.3 to top at 0.75. At h=0 (horizon) the mix t = smoothstep(-0.3,0.8,0)=0.27 → pow(0.27,0.75)=0.375. So it'd be 37% toward the top color at the horizon. Let me change the smoothstep range to (-0.05, 0.7) so the horizon is close to the bottom color. At h=0 → smoothstep(-0.05,0.7,0) = 0.0667 → pow(...,0.8) = 0.11. Good, mostly the bottom color at the horizon.

Terrain will hide most of the horizon anyway (trees + fog).

OK.

Now, code writing. Let me produce it.

```js
/* ---------------- renderer ---------------- */
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
document.body.appendChild(renderer.domElement);

/* ---------------- scene ---------------- */
const FOG_COLOR = 0x121c2e;
const scene = new THREE.Scene();
scene.background = new THREE.Color(FOG_COLOR);
scene.fog = new THREE.Fog(FOG_COLOR, 24, 110);

const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 1200);
camera.position.set(5.6, 3.4, 7.4);
```

Sky:

```js
const skyMat = new THREE.ShaderMaterial({
  side: THREE.BackSide,
  depthWrite: false,
  uniforms: {
    topColor: { value: new THREE.Color(0x01030a) },
    midColor: { value: new THREE.Color(0x0a1428) },
    bottomColor: { value: new THREE.Color(0x16233a) }
  },
  vertexShader: `varying vec3 vPos; void main(){ vPos = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
  fragmentShader: `
    uniform vec3 topColor; uniform vec3 midColor; uniform vec3 bottomColor;
    varying vec3 vPos;
    void main(){
      float h = normalize(vPos).y;
      vec3 c = mix(bottomColor, midColor, smoothstep(-0.05, 0.28, h));
      c = mix(c, topColor, smoothstep(0.2, 0.85, h));
      gl_FragColor = vec4(c, 1.0);
      #include <tonemapping_fragment>
      #include <colorspace_fragment>
    }`
});
const sky = new THREE.Mesh(new THREE.SphereGeometry(600, 32, 16), skyMat);
sky.frustumCulled = false;
scene.add(sky);
```

Camera far 1200 > 600 ✓.

Hmm — bottomColor 0x16233a vs fog 0x121c2e. At the horizon h=0, smoothstep(-0.05,0.28,0)= 0.048 → mostly bottomColor 0x16233a. Fog color 0x121c2e. Close enough. Actually let me just set bottomColor to 0x121c2e exactly to match the fog. And midColor 0x0b1526, topColor 0x01030a.

Stars:

```js
const starCount = 1800;
const starPos = new Float32Array(starCount * 3);
const starCol = new Float32Array(starCount * 3);
const sc = new THREE.Color();
for (let i = 0; i < starCount; i++) {
  const u = rand() * 2 - 1;   // y
  const phi = rand() * Math.PI * 2;
  const y = Math.pow(rand(), 0.6) * 0.98 + 0.02; // bias to upper... 
```
Better: sample a direction with y in [0.02, 1]:
```js
  const y = 0.02 + rand() * 0.98;
  const r = Math.sqrt(1 - y*y);
  const a = rand() * Math.PI * 2;
  const x = Math.cos(a) * r, z = Math.sin(a) * r;
  const R = 480;
  starPos[i*3] = x*R; starPos[i*3+1] = y*R; starPos[i*3+2] = z*R;
```
Wait, but the sky sphere is radius 600 and the camera is inside. Stars at 480 ✓ (inside sky, in front).

Hmm, but the sky is rendered with depthWrite false, so the stars will draw over it regardless of order (depth test vs nothing). Since the sky doesn't write depth, stars at 480 vs sky at 600 — if the sky is drawn first, stars pass the depth test (no depth written) and draw on top. If stars drawn first, the sky (depthWrite false, depthTest true) would draw over stars! Depth test would pass since no depth written by stars either (stars are transparent, depthWrite false by default? PointsMaterial transparent:true → depthWrite default true actually; transparent materials have depthWrite true unless set. Let me explicitly set depthWrite:false for stars).

Ordering: transparent objects render after opaque, sorted back-to-front. Sky is opaque (ShaderMaterial, transparent false) → rendered in the opaque pass first. Stars are transparent → rendered after. 

So: sky (opaque, depthWrite false) drawn in opaque pass, stars drawn later in transparent pass on top. 

Moon and glow sprite: also after. Fine.

Star colors: white to slightly blue/amber:
```js
const t = rand();
const c = new THREE.Color().setHSL(0.55 + (rand()-0.5)*0.15, 0.3*rand(), 0.55 + rand()*0.45);
```
Simpler: brightness = 0.35 + rand()*0.65, tint slight blue: r = b*0.9, g = b*0.95, bl = b.

Let's do:
```js
const b = 0.3 + Math.pow(rand(), 2) * 0.7;
starCol[i*3] = b * 0.92;
starCol[i*3+1] = b * 0.96;
starCol[i*3+2] = b;
```

Star material:
```js
const starTex = radialTexture([{o:0,c:'rgba(255,255,255,1)'},{o:0.35,c:'rgba(255,255,255,0.55)'},{o:1,c:'rgba(255,255,255,0)'}], 64);
const starMat = new THREE.PointsMaterial({
  size: 3.2, sizeAttenuation: false, map: starTex, transparent: true,
  depthWrite: false, blending: THREE.AdditiveBlending, vertexColors: true, fog: false, toneMapped: false
});
```
sizeAttenuation false → size in pixels (3.2 px). OK.

Moon:

```js
const moonDir = new THREE.Vector3(-0.55, 0.42, -0.72).normalize();
const moonPos = moonDir.clone().multiplyScalar(420);
const moon = new THREE.Mesh(new THREE.SphereGeometry(16, 24, 16), new THREE.MeshBasicMaterial({ color: 0xe8eeff, fog: false, toneMapped: false }));
moon.position.copy(moonPos);
scene.add(moon);
```
At 420 distance with radius 16 → angular diameter 4.4°. Good.

Moon glow sprite:
```js
const glowTex = radialTexture([{o:0,c:'rgba(200,220,255,0.85)'},{o:0.25,c:'rgba(150,190,255,0.35)'},{o:0.6,c:'rgba(90,130,220,0.10)'},{o:1,c:'rgba(0,0,0,0)'}], 256);
const glow = new THREE.Sprite(new THREE.SpriteMaterial({ map: glowTex, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, depthTest: false, fog: false, toneMapped: false, opacity: 0.9 }));
glow.position.copy(moonPos.clone().multiplyScalar(0.98));
glow.scale.set(220, 220, 1);
scene.add(glow);
```
Hmm, with depthTest false the glow would render over everything (including trees) — bad. Set depthTest true and it's at 400 distance so it'd be occluded by the sky? No, the sky doesn't write depth. So the glow would show behind trees correctly (trees write depth). Good, keep depthTest true.

Wait, but the sky doesn't write depth, so the glow sprite draws fine.

Sprite scale 220 at distance 420 → angular ~30°. Too big? The glow gradient fades to 0 at the edge, so effectively maybe 10-15°. Let's use 160.

The moon sphere at 420 with radius 16 is 4.4°. A glow of 160 units = 22° diameter. The visible bright part maybe half. OK.

Lights:

```js
const hemi = new THREE.HemisphereLight(0x35507a, 0x0a0d08, 1.1);
scene.add(hemi);

const moonLight = new THREE.DirectionalLight(0x9dc0ff, 1.5);
moonLight.position.copy(moonDir.clone().multiplyScalar(100));
scene.add(moonLight);
```

Fire light added later.

Ground:

```js
function terrainNoise(x, z) {...}
function groundHeight(x, z) {...}

const groundGeo = new THREE.PlaneGeometry(260, 260, 72, 72);
groundGeo.rotateX(-Math.PI/2);
const gp = groundGeo.attributes.position;
const gCol = new Float32Array(gp.count * 3);
const colA = new THREE.Color(0x39482c);  // grass lit
const colB = new THREE.Color(0x141c12);  // grass dark
const colC = new THREE.Color(0x3a3323);  // dirt
const tmpC = new THREE.Color();
for (let i = 0; i < gp.count; i++) {
  const x = gp.getX(i), z = gp.getZ(i);
  const d = Math.hypot(x, z);
  const y = groundHeight(x, z);
  gp.setY(i, y);
  const hMix = THREE.MathUtils.clamp((terrainNoise(x,z) * 0.5 + 0.5), 0, 1);
  tmpC.copy(colB).lerp(colA, hMix * 0.8 + 0.1);
  const dirt = 1 - THREE.MathUtils.smoothstep(d, 3.0, 9.0);
  tmpC.lerp(colC, dirt * 0.85);
  const shade = 0.85 + rand() * 0.3;
  gCol[i*3] = tmpC.r * shade;
  gCol[i*3+1] = tmpC.g * shade;
  gCol[i*3+2] = tmpC.b * shade;
}
groundGeo.setAttribute('color', new THREE.BufferAttribute(gCol, 3));
groundGeo.computeVertexNormals();
const ground = new THREE.Mesh(groundGeo, new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 1, metalness: 0, flatShading: true }));
ground.receiveShadow = true;
scene.add(ground);
```

Note: tmpC colors are in linear space (THREE.Color from hex converts). Multiplying by shade is fine.

Wait — careful with dirt lerp near the center: `dirt = 1 - smoothstep(d,3,9)` gives 1 at d<3, 0 at d>9. Good.

Trees:

```js
const treeParts = [];
const treeGreens = [0x1f3a24, 0x27462a, 0x18301d, 0x2d5033, 0x213f26];
const TREE_COUNT = 62;
for (let i = 0; i < TREE_COUNT; i++) {
  const a = rand() * Math.PI * 2;
  const rad = 11 + Math.pow(rand(), 0.65) * 56;  // 11..67
  const x = Math.cos(a) * rad;
  const z = Math.sin(a) * rad;
  buildTree(x, z, rnd(0.75, 1.9), treeGreens[Math.floor(rand()*treeGreens.length)]);
}
```

Hmm, with pow(rand(), 0.65) the distribution is biased toward larger radii (since pow with exponent <1 pushes toward 1). That spreads them out. Good — but we want a dense ring near the clearing for a nice look. Use `Math.pow(rand(), 1.6)` to bias toward small radius → many near radius 11-25. Let's use exponent 1.5.

Also add some far trees beyond 67 for depth up to 90 (fogged). Let's do TREE_COUNT 70 with radius 11..85.

Hmm, more trees = bigger merge. 70*4 = 280 geometries. Fine.

buildTree:

```js
function buildTree(x, z, s, green) {
  const baseY = groundHeight(x, z) - 0.35;
  const parts = [];
  const trunkH = 1.8 * s;
  const trunk = new THREE.CylinderGeometry(0.12 * s, 0.22 * s, trunkH, 5, 1);
  trunk.translate(0, trunkH / 2, 0);
  parts.push(paint(trunk, 0x2b1f16));

  const layers = [
    { r: 1.6, h: 2.4, y: 0.8 },
    { r: 1.25, h: 2.0, y: 1.85 },
    { r: 0.9, h: 1.7, y: 2.85 }
  ];
  for (let i = 0; i < layers.length; i++) {
    const L = layers[i];
    const g = new THREE.ConeGeometry(L.r * s, L.h * s, 6, 1);
    g.translate(0, (L.y + L.h / 2) * s, 0);
    parts.push(paint(g, green));
  }

  const m = new THREE.Matrix4().makeRotationY(rand() * Math.PI * 2);
  m.setPosition(x, baseY, z);
  for (const g of parts) { g.applyMatrix4(m); treeParts.push(g); }
}
```

Top height = (2.85+1.7)*s = 4.55*s → at s=1.9 → 8.6. Good.

Rocks:

```js
const rockParts = [];
function addRock(x, z, size, color) {
  const g = new THREE.IcosahedronGeometry(size, 0);
  const m = new THREE.Matrix4();
  m.makeRotationFromEuler(new THREE.Euler(rnd(-0.4,0.4), rand()*Math.PI*2, rnd(-0.4,0.4)));
  m.scale(new THREE.Vector3(rnd(0.8,1.3), rnd(0.55,0.95), rnd(0.8,1.3)));
  m.setPosition(x, groundHeight(x,z) + size*0.25, z);
  g.applyMatrix4(m);
  rockParts.push(paint(g, color));
}
```
Careful: m.scale() multiplies the matrix — makeRotation then .scale(v) → M = R * S. Then setPosition. Good.

Rocks: 16 scattered at radius 2.2-10, size 0.2-0.8, color varying gray 0x4a4a52 / 0x3a3a42.

Plus the fire ring stones: 9 at radius 1.28, size 0.2, y at ground.

Actually groundHeight near the center is 0 (flat), so stones at y = 0.1 or so.

Fire ring: for i in 0..8: angle = i/9*2π + 0.2; x = cos*1.3, z = sin*1.3; size 0.19 + rand*0.06; y = size*0.5 offset.

Fallen logs:

```js
const logParts = [];
for (let i = 0; i < 5; i++) {
  const a = rand() * Math.PI * 2;
  const r = rnd(3.0, 9.0);
  const x = Math.cos(a) * r, z = Math.sin(a) * r;
  const len = rnd(2.2, 4.5);
  const rad = rnd(0.16, 0.30);
  const g = new THREE.CylinderGeometry(rad * 0.85, rad, len, 7, 1);
  // lay it down
  const m = new THREE.Matrix4();
  m.makeRotationZ(Math.PI / 2);          // along x
  g.applyMatrix4(m);
  const rotY = new THREE.Matrix4().makeRotationY(rand() * Math.PI * 2);
  g.applyMatrix4(rotY);
  g.translate(x, groundHeight(x, z) + rad * 0.85, z);
  logParts.push(paint(g, 0x30231a));
}
```
Rotation order: first rotate Z to lay it along x, then rotate Y — but rotY applied to the already-transformed geometry works. Then translate. Good.

Now the fire logs (teepee) as described, separate mesh with its own material (needs to be lit warmly, and vertexColors).

Actually I can merge the teepee logs into logParts (same material: MeshStandardMaterial vertexColors flatShading). Yes! All wood → one mesh. Good, and it can cast/receive shadows.

So push the teepee logs into logParts too.

Then:
```js
const woodGeo = mergeGeometries(logParts, false);
const woodMesh = new THREE.Mesh(woodGeo, new THREE.MeshStandardMaterial({ vertexColors: true, flatShading: true, roughness: 0.95, metalness: 0 }));
woodMesh.castShadow = true; woodMesh.receiveShadow = true;
scene.add(woodMesh);
```

Similarly trees and rocks.

Sparks & flames.

Fire point light.

Let me write the flame geometry function:

```js
function flameGeometry(r, h) {
  const g = new THREE.ConeGeometry(r, h, 6, 3, false);
  const p = g.attributes.position;
  for (let i = 0; i < p.count; i++) {
    const y = p.getY(i);
    const k = (y + h / 2) / h;  // 0 base, 1 tip
    const j = (1 - k) * 0.5 + 0.1;
    p.setX(i, p.getX(i) + (rand() - 0.5) * r * 0.35 * j);
    p.setZ(i, p.getZ(i) + (rand() - 0.5) * r * 0.35 * j);
    p.setY(i, y + (rand() - 0.5) * h * 0.06);
  }
  g.translate(0, h / 2, 0);
  g.computeVertexNormals();
  return g;
}
```
Note: ConeGeometry is non-indexed? ConeGeometry extends CylinderGeometry → indexed? CylinderGeometry builds non-indexed vertices via indices... Actually CylinderGeometry creates an indexed geometry. Hmm, for cones the tip vertices are duplicated anyway. Jittering indexed geometry moves shared vertices consistently. That's fine either way.

Wait — if it's indexed, then randomizing per vertex index moves shared vertices consistently (each vertex moves once), so no cracks except at seams. Good.

Since MeshBasicMaterial + additive, normals don't matter. Skip computeVertexNormals.

Flame creation:

```js
const flames = [];
const flameDefs = [...];
for (const d of flameDefs) {
  const geo = flameGeometry(d.r, d.h);
  const mat = new THREE.MeshBasicMaterial({ color: d.color, transparent: true, opacity: d.opacity, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide, fog: false, toneMapped: false });
  const mesh = new THREE.Mesh(geo, mat);
  mesh.position.set(d.x, d.y, d.z);
  mesh.renderOrder = 2;
  scene.add(mesh);
  flames.push({ mesh, def: d, phase: rand() * Math.PI * 2, bx: d.x, bz: d.z });
}
```

toneMapped false for flames makes them vivid. Additive + bright colors → with ACES they'd get compressed. Keep toneMapped false for punch.

Hmm, but with toneMapped false and additive blending in linear space, the color 0xff4400 (linear ~ (1, 0.06, 0)) added to the framebuffer... it'll look saturated orange. Fine.

Animation as described.

Now let's write the controls.

```js
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1.35, 0);
controls.enableDamping = true;
controls.dampingFactor = 0.07;
controls.rotateSpeed = 0.6;
controls.zoomSpeed = 0.7;
controls.enablePan = false;
controls.minDistance = 3.5;
controls.maxDistance = 34;
controls.minPolarAngle = 0.25;
controls.maxPolarAngle = Math.PI * 0.495;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.45;
controls.addEventListener('start', () => { controls.autoRotate = false; });
controls.update();
```

Hmm, minPolarAngle 0.25 rad (14°) allows looking from nearly above. Fine.

maxPolarAngle 0.495π = 89.1° → camera slightly above the target's horizontal plane. Target y = 1.35, so at max distance 34 the camera y = 1.35 + 34*cos(89.1°) = 1.35 + 0.53 = 1.88. Above ground ✓.

Note: with autoRotate, the camera orbits. Fine.

Now the animate loop and resize.

Also add `controls.update()` inside animate.

Wire up the hint fade:
```js
setTimeout(() => { document.getElementById('hint').style.opacity = '0'; }, 8000);
```
Hmm the screenshot is at 3s — keep the hint visible then. Fine, 8s.

Actually maybe keep the hint permanently but subtle. It communicates interactivity. I'll fade at 9s.

Now let's consider: is anything blocking the first paint? Module loading from CDN — fine.

Let me write out the final code completely and check for errors.

One thing: `mergeGeometries` will warn if geometries have different attributes; all good.

Potential issue: `paint()` on an indexed geometry with position.count vertices — fine.

Another: for the tree geometry merge, ConeGeometry and CylinderGeometry have attributes position, normal, uv, and we add color. ✓.

For rock merge: IcosahedronGeometry (PolyhedronGeometry) has position, normal, uv, indexed? PolyhedronGeometry is non-indexed. All rocks non-indexed ✓.

Let me double check that mergeGeometries requires matching `groups`? No.

Also `mergeGeometries(geoms, false)` — useGroups false is fine as long as there's one material.

Now writing flame update:

```js
function updateFlames(t) {
  for (let i = 0; i < flames.length; i++) {
    const f = flames[i], d = f.def;
    const p = f.phase;
    const flick = 0.82 + 0.20 * Math.sin(t * d.sp + p) + 0.10 * Math.sin(t * d.sp * 2.7 + p * 1.7);
    const w = 0.88 + 0.14 * Math.sin(t * d.sp * 1.6 + p * 2.1);
    f.mesh.scale.set(d.r * w, d.h * flick, d.r * w);
    f.mesh.position.x = f.bx + Math.sin(t * 2.4 + p) * 0.07;
    f.mesh.position.z = f.bz + Math.cos(t * 2.1 + p * 1.3) * 0.07;
    f.mesh.position.y = d.y + Math.sin(t * 3.3 + p) * 0.04;
    f.mesh.rotation.y = t * 0.9 + p;
    f.mesh.material.opacity = d.opacity * (0.72 + 0.28 * Math.sin(t * d.sp * 1.3 + p));
  }
}
```
Wait, scale.set(d.r*w, d.h*flick, d.r*w) — the geometry is already built with radius d.r and height d.h. Scaling by w and flick (≈1) is correct. Good.

Hmm flick could go 0.52..1.12. Fine.

Now the light:

```js
const FIRE_LIGHT_BASE = 42;
const fireLight = new THREE.PointLight(0xff8a3c, FIRE_LIGHT_BASE, 60, 2);
fireLight.position.set(0, 1.5, 0);
fireLight.castShadow = true;
fireLight.shadow.mapSize.set(1024, 1024);
fireLight.shadow.camera.near = 0.4;
fireLight.shadow.camera.far = 42;
fireLight.shadow.bias = -0.0035;
fireLight.shadow.normalBias = 0.03;
scene.add(fireLight);
```

Note: PointLight constructor (color, intensity, distance, decay).

`distance: 60` with decay 2 — three uses `getDistanceAttenuation` with cutoff: when distance > 0, there's an extra windowing factor. Fine.

Now sparks.

```js
const SPARK_COUNT = 320;
const sparkPos = new Float32Array(SPARK_COUNT * 3);
const sparkCol = new Float32Array(SPARK_COUNT * 3);
const sparkVel = new Float32Array(SPARK_COUNT * 3);
const sparkLife = new Float32Array(SPARK_COUNT);
const sparkMax = new Float32Array(SPARK_COUNT);
```

resetSpark as described.

```js
const sparkGeo = new THREE.BufferGeometry();
sparkGeo.setAttribute('position', new THREE.BufferAttribute(sparkPos, 3));
sparkGeo.setAttribute('color', new THREE.BufferAttribute(sparkCol, 3));
const sparkMat = new THREE.PointsMaterial({
  size: 0.09, map: sparkTex, vertexColors: true, transparent: true,
  blending: THREE.AdditiveBlending, depthWrite: false, sizeAttenuation: true,
  fog: false, toneMapped: false, alphaTest: 0.001
});
```
Hmm alphaTest with additive... skip alphaTest. But depthTest true means sparks behind logs get occluded — correct.

Points at y up to ~4 near the fire — fine.

sparkTex radial white.

Now updateSparks loops.

Warm-up: after everything, run 240 steps of 1/60 with t advancing. But updateSparks uses `t` for swirl. Pass the time.

Actually the warm-up: `for (let k = 0; k < 180; k++) updateSparks(1/60, k/60);` This advances life and positions. Since resetSpark is called when life >= max, they'll respawn. Good.

Now let's make sure `updateSparks` is defined before the warm-up call — hoisted function declarations are fine if I call after definition anyway.

Also, `sparkGeo.attributes.position.needsUpdate = true;` and color.

Also for bounding sphere issues: Points with dynamic positions — set `sparkGeo.boundingSphere = new THREE.Sphere(new THREE.Vector3(0,1.5,0), 12)` to avoid frustum culling issues. Or `sparkMesh.frustumCulled = false;`. Use frustumCulled = false.

OK. Writing the final answer now.

One more: the renderer's canvas fills the window; body has overflow hidden.

Also `document.getElementById('hint')`.

Let me assemble the complete file.

Also, should I disable context menu? Not needed.

Final check on the `#include <tonemapping_fragment>` in a raw ShaderMaterial: the chunk is:
```glsl
#if defined( TONE_MAPPING )
  gl_FragColor.rgb = toneMapping( gl_FragColor.rgb );
#endif
```
The `toneMapping` function and `TONE_MAPPING` define are injected into the fragment prefix by WebGLProgram when `parameters.toneMapping !== NoToneMapping`. That parameter is `material.toneMapped ? renderer.toneMapping : NoToneMapping`. ShaderMaterial default toneMapped = true. ✓

And `<colorspace_fragment>`:
```glsl
gl_FragColor = linearToOutputTexel( gl_FragColor );
```
which is defined in the prefix too. ✓

Good.

Now, is `#include` processed for ShaderMaterial? Yes, WebGLProgram resolves includes via `resolveIncludes` for all shaders. ✓

Writing it out.

One last consideration about the sky sphere: radius 600, camera far 1200. `sky.frustumCulled = false` — good since the camera is inside.

Also the moon at 420 and stars at 480 are inside the sky sphere (600) ✓.

Let me write everything.

Actually one more thought on `groundHeight` being used for tree placement at radius up to 85: terrainNoise can give ±4.6, times 0.42 → ±1.9. Fine.

Writing now.

Also `THREE.MathUtils.smoothstep(x, min, max)` exists ✓.

Final code writing.