Models 44

GLM 5.3

by Z.aiReasoningClean
Open in new window

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

How it went

Total time
9 min 52 s
Cost
$0.243
Output tokens
80,872
Spent thinking
69,188 tokens
Speed
136.9 tok/s
Console errors
0
First token after
1.3 s
First visible text after
9 min 16 s
Prompt tokens
559
Code size
32.5 KB
Finish reason
stop
Served by
CoreWeave
Model id
z-ai/glm-5.3
Reasoning sent
model default
Max output
943,718 tokens
Generated
Nov 12, 2026

What it was asked

The prompt
Build a low-poly 3D scene of a campfire in a forest clearing at night, with Three.js in a single HTML file. Load Three.js only through this import map (do not use any other URL or version): <script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"}}</script> Requirements: - A ground with a few low-poly rocks and logs around a campfire in the center. - At least 30 low-poly trees placed randomly around the clearing (not on the fire). - The campfire has animated flames and a warm point light that flickers and casts shadows. - Sparks rising from the fire as a simple particle system. - A dark night sky with stars and a moon, plus fog that fades the distant trees. - OrbitControls to look around, with limits so the camera stays above the ground. - The renderer fills the window and handles resizing correctly.
System prompt, sent with every prompt
You are generating a self-contained web demo. It will be rendered automatically in a sandboxed iframe and compared side by side with the output of other AI models given the exact same task.

Output rules:
- Reply with exactly ONE fenced code block containing the complete file: ```html (or ```svg when the task asks for an SVG). No text before or after the code block.
- Everything must be inline: CSS in <style>, JavaScript in <script>. No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task.
- Do not use localStorage, sessionStorage, cookies, IndexedDB, alert(), confirm(), prompt() or window.open(): they are blocked in the sandbox and will throw.
- The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar.
- Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction.
- Everything that makes the demo interesting must be visible within the first seconds: nothing hidden behind a high score, a later level or a long play session.
- Games must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" message, so the game can be judged in action without playing.
- The iframe only receives keyboard events after the user clicks it: if the demo uses the keyboard, display a clear "Click or press a key to start" message.
- The code must run with zero errors in the console of the latest Chrome.

Code run-1.html · 854 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Emberwood · a low-poly night camp</title>
<style>
  html, body { height: 100%; }
  body {
    margin: 0; overflow: hidden; background: #04060c;
    font-family: Georgia, "Times New Roman", serif;
    -webkit-font-smoothing: antialiased;
  }
  canvas { display: block; }

  #vignette {
    position: fixed; inset: 0; z-index: 3; pointer-events: none;
    background: radial-gradient(135% 105% at 50% 42%, rgba(0,0,0,0) 48%, rgba(2,3,9,.6) 100%);
  }
  .brand {
    position: fixed; top: 26px; left: 30px; z-index: 4; pointer-events: none;
    display: flex; align-items: flex-start; gap: 13px;
  }
  .brand svg { margin-top: 2px; filter: drop-shadow(0 0 9px rgba(255,150,60,.55)); }
  .brand h1 {
    margin: 0; font-size: 16px; font-weight: 400; letter-spacing: .48em;
    text-transform: uppercase; color: #ecd9b0;
    text-shadow: 0 0 26px rgba(255,150,60,.35);
  }
  .brand p {
    margin: 7px 0 0; font-family: Verdana, Arial, sans-serif; font-size: 8.5px;
    letter-spacing: .34em; text-transform: uppercase; color: rgba(214,196,158,.52);
  }
  #hint {
    position: fixed; z-index: 4; left: 50%; bottom: 28px; transform: translateX(-50%);
    pointer-events: none; white-space: nowrap;
    font-family: Verdana, Arial, sans-serif; font-size: 10px;
    letter-spacing: .3em; text-transform: uppercase; color: rgba(235,218,182,.82);
    padding: 12px 22px 12px 25px; border: 1px solid rgba(235,200,150,.15);
    border-radius: 999px; background: rgba(6,8,14,.5);
    backdrop-filter: blur(6px); transition: opacity .9s ease;
  }
  #hint.hide { opacity: 0; }
  #loading {
    position: fixed; inset: 0; z-index: 10; display: flex; align-items: center; justify-content: center;
    background: #04060c; color: rgba(226,206,164,.8);
    font-family: Verdana, Arial, sans-serif; font-size: 10.5px;
    letter-spacing: .42em; text-transform: uppercase; transition: opacity .7s ease;
  }
  @media (max-width: 600px){
    .brand { top: 18px; left: 20px; }
    .brand h1 { font-size: 14px; }
    #hint { bottom: 18px; font-size: 8.5px; padding: 10px 16px 10px 19px; }
  }
</style>
</head>
<body>
<div id="vignette"></div>
<div class="brand">
  <svg width="14" height="18" viewBox="0 0 14 18" aria-hidden="true">
    <defs>
      <linearGradient id="fg" x1="7" y1="18" x2="7" y2="0" gradientUnits="userSpaceOnUse">
        <stop stop-color="#ff7a1a"/><stop offset="1" stop-color="#ffd873"/>
      </linearGradient>
    </defs>
    <path d="M7 .8C8.2 4.3 12 6.1 12 10.3 12 14 9.9 16.6 7 16.6S2 14 2 10.3c0-2.1 1-3.6 2.6-5.2.2 2 .9 2.9 1.9 3.3C6 5.9 6.4 3.2 7 .8Z" fill="url(#fg)"/>
  </svg>
  <div>
    <h1>Emberwood</h1>
    <p>low-poly camp · night clearing</p>
  </div>
</div>
<div id="hint">drag to orbit · scroll to zoom</div>
<div id="loading">stoking the fire…</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';

/* ================= helpers ================= */
const rand = (a, b) => a + Math.random() * (b - a);

function groundHeight(x, z){
  const d = Math.hypot(x, z);
  const flat = THREE.MathUtils.smoothstep(d, 2.5, 12);   // keep the clearing flat
  const h = Math.sin(x * 0.16) * Math.cos(z * 0.19) * 0.9
          + Math.sin(x * 0.42 + 1.7) * Math.cos(z * 0.35 + 0.6) * 0.42
          + Math.sin(x * 0.85 + 0.3) * Math.sin(z * 0.78 + 1.1) * 0.18;
  return h * flat;
}

function radialTex(stops, size = 256){
  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 [o, c] of stops) g.addColorStop(o, c);
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, size, size);
  const tex = new THREE.CanvasTexture(cv);
  tex.colorSpace = THREE.SRGBColorSpace;
  return tex;
}

/* shared uniforms (updated once per frame) */
const uniTime  = { value: 0 };
const uniScale = { value: 1 };   // converts world size -> gl_PointSize
const uniPR    = { value: 1 };

/* ================= renderer / scene / camera ================= */
const scene = new THREE.Scene();
const FOG_COLOR = new THREE.Color(0x0d1526);
scene.fog = new THREE.Fog(FOG_COLOR, 20, 82);
scene.background = new THREE.Color(0x04060c);

const camera = new THREE.PerspectiveCamera(55, innerWidth / innerHeight, 0.1, 1600);
camera.position.set(8.5, 3.0, 14.0);

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

const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1.1, 0);
controls.enableDamping = true;
controls.dampingFactor = 0.06;
controls.enablePan = false;
controls.minDistance = 5;
controls.maxDistance = 19;
controls.minPolarAngle = 0.15;
controls.maxPolarAngle = Math.PI / 2 - 0.07;   // camera always stays above the ground
controls.autoRotate = true;
controls.autoRotateSpeed = -0.4;
controls.update();

/* ================= lights ================= */
const moonDir = new THREE.Vector3(-0.45, 0.30, -0.84).normalize();

scene.add(new THREE.HemisphereLight(0x35507a, 0x181310, 0.55));   // cool night fill

const moonLight = new THREE.DirectionalLight(0x9db8e8, 0.55);
moonLight.position.copy(moonDir).multiplyScalar(90);
moonLight.castShadow = true;
moonLight.shadow.mapSize.set(2048, 2048);
moonLight.shadow.camera.near = 20;
moonLight.shadow.camera.far = 220;
moonLight.shadow.camera.left = -62;
moonLight.shadow.camera.right = 62;
moonLight.shadow.camera.top = 62;
moonLight.shadow.camera.bottom = -62;
moonLight.shadow.camera.updateProjectionMatrix();
moonLight.shadow.bias = -0.0004;
moonLight.shadow.normalBias = 0.15;
scene.add(moonLight);

const fireLight = new THREE.PointLight(0xff8c3a, 230, 60, 2);
fireLight.position.set(0, 1.25, 0);
fireLight.castShadow = true;
fireLight.shadow.mapSize.set(1024, 1024);
fireLight.shadow.camera.near = 0.08;
fireLight.shadow.camera.far = 60;
fireLight.shadow.bias = -0.006;
fireLight.shadow.normalBias = 0.04;
scene.add(fireLight);

/* ================= night sky dome ================= */
const sky = new THREE.Mesh(
  new THREE.SphereGeometry(700, 40, 24),
  new THREE.ShaderMaterial({
    side: THREE.BackSide,
    depthWrite: false,
    fog: false,
    uniforms: {
      uHorizon: { value: FOG_COLOR },
      uZenith:  { value: new THREE.Color(0x020711) },
      uFloor:   { value: new THREE.Color(0x04050a) },
      uMoonDir: { value: moonDir },
      uMoonTint:{ value: new THREE.Color(0x8fa8d8) }
    },
    vertexShader: `
      varying vec3 vDir;
      void main(){
        vDir = position;
        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
      }`,
    fragmentShader: `
      uniform vec3 uHorizon;
      uniform vec3 uZenith;
      uniform vec3 uFloor;
      uniform vec3 uMoonDir;
      uniform vec3 uMoonTint;
      varying vec3 vDir;
      void main(){
        vec3 d = normalize(vDir);
        vec3 col = mix(uHorizon, uZenith, pow(clamp(d.y, 0.0, 1.0), 0.45));
        col = mix(uFloor, col, smoothstep(-0.22, 0.01, d.y));
        float m = clamp(dot(d, uMoonDir), 0.0, 1.0);
        col += uMoonTint * (pow(m, 400.0) * 0.6 + pow(m, 30.0) * 0.10);
        float n = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453);
        col += (n - 0.5) * 0.005;   // dither against banding
        gl_FragColor = vec4(col, 1.0);
        #include <tonemapping_fragment>
        #include <colorspace_fragment>
      }`
  })
);
sky.renderOrder = -10;
scene.add(sky);

/* ================= moon + halo ================= */
const moon = new THREE.Mesh(
  new THREE.IcosahedronGeometry(16, 1),
  new THREE.MeshBasicMaterial({ color: 0xd7e4f6, fog: false })
);
moon.position.copy(moonDir).multiplyScalar(585);
scene.add(moon);

const halo = new THREE.Sprite(new THREE.SpriteMaterial({
  map: radialTex([[0, 'rgba(210,225,255,0.5)'], [0.25, 'rgba(160,185,240,0.16)'], [0.55, 'rgba(120,150,220,0.05)'], [1, 'rgba(80,110,190,0)']]),
  blending: THREE.AdditiveBlending, depthWrite: false, transparent: true, fog: false
}));
halo.position.copy(moonDir).multiplyScalar(580);
halo.scale.set(150, 150, 1);
scene.add(halo);

/* ================= stars (twinkling) ================= */
const starGeo = new THREE.BufferGeometry();
{
  const N = 1100;
  const pos = new Float32Array(N * 3), col = new Float32Array(N * 3);
  const size = new Float32Array(N), phase = new Float32Array(N), tw = new Float32Array(N);
  const c = new THREE.Color();
  for (let i = 0; i < N; i++){
    const y = 0.03 + Math.random() * 0.96;
    const rr = Math.sqrt(Math.max(0, 1 - y * y));
    const a = Math.random() * Math.PI * 2;
    pos[i * 3] = Math.cos(a) * rr * 600;
    pos[i * 3 + 1] = y * 600;
    pos[i * 3 + 2] = Math.sin(a) * rr * 600;
    const t = Math.random();
    if (t < 0.72) c.set(0xdfe8ff);
    else if (t < 0.87) c.set(0xffe9c0);
    else c.set(0xaec8ff);
    c.multiplyScalar(rand(0.45, 1.0));
    col[i * 3] = c.r; col[i * 3 + 1] = c.g; col[i * 3 + 2] = c.b;
    size[i] = 1.1 + Math.pow(Math.random(), 3.0) * 3.4;
    phase[i] = Math.random() * Math.PI * 2;
    tw[i] = rand(0.3, 2.2);
  }
  starGeo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
  starGeo.setAttribute('aColor', new THREE.BufferAttribute(col, 3));
  starGeo.setAttribute('aSize', new THREE.BufferAttribute(size, 1));
  starGeo.setAttribute('aPhase', new THREE.BufferAttribute(phase, 1));
  starGeo.setAttribute('aTw', new THREE.BufferAttribute(tw, 1));
}
const stars = new THREE.Points(starGeo, new THREE.ShaderMaterial({
  transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
  uniforms: { uTime: uniTime, uPR: uniPR },
  vertexShader: `
    attribute vec3 aColor;
    attribute float aSize;
    attribute float aPhase;
    attribute float aTw;
    uniform float uTime;
    uniform float uPR;
    varying vec3 vColor;
    varying float vAlpha;
    void main(){
      vColor = aColor;
      float tw = 0.5 + 0.5 * sin(uTime * aTw + aPhase);
      vAlpha = mix(1.0, 0.35 + 0.65 * tw, 0.85);
      vec4 mv = modelViewMatrix * vec4(position, 1.0);
      gl_PointSize = aSize * uPR * (0.8 + 0.35 * tw);
      gl_Position = projectionMatrix * mv;
    }`,
  fragmentShader: `
    varying vec3 vColor;
    varying float vAlpha;
    void main(){
      vec2 uv = gl_PointCoord - 0.5;
      float d = length(uv) * 2.0;
      float a = (1.0 - smoothstep(0.15, 1.0, d)) * vAlpha;
      if (a < 0.02) discard;
      gl_FragColor = vec4(vColor, a);
      #include <colorspace_fragment>
    }`
}));
stars.frustumCulled = false;
scene.add(stars);

/* ================= low-poly ground ================= */
const groundGeo = new THREE.PlaneGeometry(150, 150, 84, 84);
groundGeo.rotateX(-Math.PI / 2);
{
  const p = groundGeo.attributes.position;
  const colors = new Float32Array(p.count * 3);
  const cA = new THREE.Color(0x20301b), cB = new THREE.Color(0x314224);
  const cDirt = new THREE.Color(0x40301d), cDark = new THREE.Color(0x141c0e);
  const c = new THREE.Color();
  for (let i = 0; i < p.count; i++){
    const x = p.getX(i), z = p.getZ(i);
    p.setY(i, groundHeight(x, z));
    const d = Math.hypot(x, z);
    c.copy(cA).lerp(cB, THREE.MathUtils.clamp(0.5 + 0.5 * Math.sin(x * 0.33 + 2.1) * Math.cos(z * 0.29 + 0.7), 0, 1));
    c.lerp(cDark, THREE.MathUtils.clamp(0.35 + 0.35 * Math.sin(x * 0.81 + 5.0) * Math.sin(z * 0.9 + 1.3), 0, 0.6));
    c.lerp(cDirt, (1 - THREE.MathUtils.smoothstep(d, 1.3, 3.6)) * 0.85);   // trampled earth around the fire
    colors[i * 3] = c.r; colors[i * 3 + 1] = c.g; colors[i * 3 + 2] = c.b;
  }
  groundGeo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
}
groundGeo.computeVertexNormals();
const ground = new THREE.Mesh(groundGeo, new THREE.MeshStandardMaterial({
  vertexColors: true, flatShading: true, roughness: 1, metalness: 0
}));
ground.receiveShadow = true;
scene.add(ground);

/* ================= rocks ================= */
const rockPalette = [0x4b5158, 0x555b60, 0x3f464e, 0x5c6167];
function makeRock(size, color){
  const geo = new THREE.IcosahedronGeometry(size, 0);
  const p = geo.attributes.position;
  const seen = new Map();
  const v = new THREE.Vector3();
  for (let i = 0; i < p.count; i++){
    v.fromBufferAttribute(p, i);
    const key = v.x.toFixed(3) + '_' + v.y.toFixed(3) + '_' + v.z.toFixed(3);
    let o = seen.get(key);   // weld shared vertices so faces never crack
    if (!o){
      o = { x: rand(-1, 1) * size * 0.4, y: rand(-0.6, 0.6) * size * 0.35, z: rand(-1, 1) * size * 0.4 };
      seen.set(key, o);
    }
    p.setXYZ(i, v.x + o.x, v.y + o.y, v.z + o.z);
  }
  geo.computeVertexNormals();
  const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ color, flatShading: true, roughness: 0.95 }));
  mesh.castShadow = mesh.receiveShadow = true;
  return mesh;
}
function scatterRock(rMin, rMax, sMin, sMax, count){
  for (let i = 0; i < count; i++){
    const a = rand(0, Math.PI * 2), r = rand(rMin, rMax);
    const x = Math.cos(a) * r, z = Math.sin(a) * r;
    const s = rand(sMin, sMax);
    const rock = makeRock(s, rockPalette[(Math.random() * rockPalette.length) | 0]);
    rock.position.set(x, groundHeight(x, z) + s * 0.45, z);
    rock.rotation.set(rand(0, 0.4), rand(0, Math.PI * 2), rand(0, 0.4));
    rock.scale.set(rand(0.8, 1.3), rand(0.7, 1.1), rand(0.8, 1.3));
    scene.add(rock);
  }
}
scatterRock(3.2, 11.5, 0.2, 0.55, 9);   // stones in the clearing
scatterRock(10, 15, 0.9, 1.5, 3);        // a couple of boulders

/* stone ring around the fire */
for (let i = 0; i < 10; i++){
  const a = (i / 10) * Math.PI * 2 + rand(-0.12, 0.12);
  const r = 1.08 + rand(-0.09, 0.09);
  const s = rand(0.16, 0.26);
  const rock = makeRock(s, rockPalette[(Math.random() * rockPalette.length) | 0]);
  rock.position.set(Math.cos(a) * r, s * 0.4, Math.sin(a) * r);
  rock.rotation.set(rand(0, 0.5), rand(0, 6.28), rand(0, 0.5));
  rock.scale.set(rand(0.9, 1.35), rand(0.6, 0.9), rand(0.9, 1.35));
  scene.add(rock);
}

/* ================= logs ================= */
const barkMat = new THREE.MeshStandardMaterial({ color: 0x4a3524, flatShading: true, roughness: 1 });
function addFallenLog(len, rad, x, z, yaw){
  const m = new THREE.Mesh(new THREE.CylinderGeometry(rad * 0.9, rad, len, 7), barkMat);
  m.position.set(x, groundHeight(x, z) + rad * 0.75, z);
  m.rotation.set(rand(-0.05, 0.05), yaw, Math.PI / 2);
  m.castShadow = m.receiveShadow = true;
  scene.add(m);
}
for (let i = 0; i < 3; i++){   // fallen logs scattered in the clearing
  const a = rand(0, 6.28), r = rand(5, 10.5);
  addFallenLog(rand(2.2, 3.6), rand(0.18, 0.3), Math.cos(a) * r, Math.sin(a) * r, rand(0, 6.28));
}
/* two sitting logs flanking the fire */
addFallenLog(2.4, 0.21, Math.cos(3.6) * 2.75, Math.sin(3.6) * 2.75, 3.6 + Math.PI / 2);
addFallenLog(2.4, 0.21, Math.cos(0.9) * 2.9, Math.sin(0.9) * 2.9, 0.9 + Math.PI / 2);

/* firewood pile */
{
  const pile = new THREE.Group();
  const stickGeo = new THREE.CylinderGeometry(0.07, 0.09, 1.25, 6);
  stickGeo.rotateZ(Math.PI / 2);
  for (const s of [[0, 0.08, -0.12], [0, 0.08, 0.02], [0, 0.08, 0.16], [0.05, 0.21, -0.05], [-0.06, 0.21, 0.07]]){
    const m = new THREE.Mesh(stickGeo, barkMat);
    m.position.set(s[0], s[1], s[2]);
    m.rotation.x = rand(-0.06, 0.06);
    m.castShadow = m.receiveShadow = true;
    pile.add(m);
  }
  pile.position.set(2.35, groundHeight(2.35, -3.5), -3.5);
  pile.rotation.y = rand(0, 3);
  scene.add(pile);
}

/* ================= tent ================= */
{
  const shape = new THREE.Shape();
  shape.moveTo(-1.3, 0); shape.lineTo(1.3, 0); shape.lineTo(0, 1.75); shape.closePath();
  const tentGeo = new THREE.ExtrudeGeometry(shape, { depth: 3.0, bevelEnabled: false });
  tentGeo.translate(0, 0, -1.5);
  const tent = new THREE.Mesh(tentGeo, new THREE.MeshStandardMaterial({ color: 0x7d5b3e, flatShading: true, roughness: 0.9 }));
  const tx = 3.8, tz = -6.8;
  tent.position.set(tx, groundHeight(tx, tz) - 0.04, tz);
  tent.rotation.y = Math.atan2(-tx, -tz);   // opening faces the fire
  tent.castShadow = tent.receiveShadow = true;
  scene.add(tent);

  const openShape = new THREE.Shape();
  openShape.moveTo(-0.95, 0); openShape.lineTo(0.95, 0); openShape.lineTo(0, 1.28); openShape.closePath();
  const open = new THREE.Mesh(new THREE.ShapeGeometry(openShape), new THREE.MeshBasicMaterial({ color: 0x060407 }));
  open.position.set(0, 0.03, 1.53);
  tent.add(open);
}

/* ================= grass tufts ================= */
{
  const gGeo = new THREE.ConeGeometry(0.06, 0.55, 4, 1, true);
  gGeo.translate(0, 0.27, 0);
  const gMat = new THREE.MeshStandardMaterial({ color: 0xffffff, flatShading: true, roughness: 1, side: THREE.DoubleSide });
  const COUNT = 260;
  const grass = new THREE.InstancedMesh(gGeo, gMat, COUNT);
  grass.frustumCulled = false;
  const d = new THREE.Object3D(), c = new THREE.Color();
  const gA = new THREE.Color(0x27391c), gB = new THREE.Color(0x3c4d26);
  for (let i = 0; i < COUNT; i++){
    const a = rand(0, 6.28), r = 2.8 + Math.sqrt(Math.random()) * 13;
    const x = Math.cos(a) * r, z = Math.sin(a) * r;
    d.position.set(x, groundHeight(x, z) - 0.02, z);
    d.rotation.set(rand(-0.35, 0.35), rand(0, 6.28), rand(-0.35, 0.35));
    const s = rand(0.5, 1.5);
    d.scale.set(s, s * rand(0.8, 1.6), s);
    d.updateMatrix();
    grass.setMatrixAt(i, d.matrix);
    grass.setColorAt(i, c.copy(gA).lerp(gB, Math.random()));
  }
  grass.instanceColor.needsUpdate = true;
  scene.add(grass);
}

/* ================= trees (instanced, 52 of them) ================= */
const spots = [];
{
  let guard = 0;
  while (spots.length < 52 && guard++ < 4000){
    const a = rand(0, Math.PI * 2);
    const r = 20 + Math.sqrt(Math.random()) * 30;   // clearing radius ~20, never on the fire
    const x = Math.cos(a) * r, z = Math.sin(a) * r;
    let ok = true;
    for (const p of spots){
      const dx = p.x - x, dz = p.z - z;
      if (dx * dx + dz * dz < 7.3){ ok = false; break; }
    }
    if (ok) spots.push({ x, z, s: rand(0.8, 1.5), h: rand(0.9, 1.25), rot: rand(0, 6.28), pine: Math.random() < 0.7 });
  }
}
const pines = spots.filter(s => s.pine);
const leafs = spots.filter(s => !s.pine);

const trunkMat = new THREE.MeshStandardMaterial({ color: 0xffffff, flatShading: true, roughness: 1 });
const foliageMat = new THREE.MeshStandardMaterial({ color: 0xffffff, flatShading: true, roughness: 0.95 });
const dum = new THREE.Object3D();
const cTmp = new THREE.Color();
const trunkColA = new THREE.Color(0x3a2a1c), trunkColB = new THREE.Color(0x54402a);

function translated(geo, y){ geo.translate(0, y, 0); return geo; }

const pineTrunk = new THREE.InstancedMesh(
  translated(new THREE.CylinderGeometry(0.13, 0.22, 1.7, 5), 0.85), trunkMat, pines.length
);
pineTrunk.castShadow = true; pineTrunk.frustumCulled = false;
scene.add(pineTrunk);

const pineCones = [[1.35, 2.0, 0.45], [1.02, 1.7, 1.45], [0.64, 1.45, 2.42]].map(([r, h, y]) => {
  const g = new THREE.ConeGeometry(r, h, 7);
  g.translate(0, y + h / 2, 0);
  const im = new THREE.InstancedMesh(g, foliageMat, pines.length);
  im.castShadow = true; im.frustumCulled = false;
  scene.add(im);
  return im;
});
const pineCols = [0x1c3a24, 0x214a2b, 0x18321f, 0x1e4433].map(h => new THREE.Color(h));

pines.forEach((p, i) => {
  dum.position.set(p.x, groundHeight(p.x, p.z) - 0.06, p.z);
  dum.rotation.set(0, p.rot, 0);
  dum.scale.set(p.s, p.s * p.h, p.s);
  dum.updateMatrix();
  pineTrunk.setMatrixAt(i, dum.matrix);
  pineTrunk.setColorAt(i, cTmp.copy(trunkColA).lerp(trunkColB, Math.random()));
  const fc = pineCols[(Math.random() * pineCols.length) | 0].clone()
    .offsetHSL(rand(-0.02, 0.02), rand(-0.08, 0.08), rand(-0.03, 0.03));
  pineCones.forEach((im, k) => {
    im.setMatrixAt(i, dum.matrix);
    im.setColorAt(i, cTmp.copy(fc).multiplyScalar(0.78 + k * 0.16));
  });
});
[pineTrunk, ...pineCones].forEach(im => {
  im.instanceMatrix.needsUpdate = true;
  if (im.instanceColor) im.instanceColor.needsUpdate = true;
});

/* deciduous trees: trunk + three jittered blobs */
const leafTrunks = new THREE.InstancedMesh(
  translated(new THREE.CylinderGeometry(0.15, 0.26, 2.4, 5), 1.2), trunkMat, leafs.length
);
leafTrunks.castShadow = true; leafTrunks.frustumCulled = false;
scene.add(leafTrunks);

const blobs = new THREE.InstancedMesh(new THREE.IcosahedronGeometry(1.25, 0), foliageMat, leafs.length * 3);
blobs.castShadow = true; blobs.frustumCulled = false;
scene.add(blobs);

const blobDefs = [
  { p: new THREE.Vector3(0, 2.85, 0), s: 1.0 },
  { p: new THREE.Vector3(0.8, 2.2, 0.45), s: 0.62 },
  { p: new THREE.Vector3(-0.65, 2.45, -0.4), s: 0.55 }
];
const leafCols = [0x27432a, 0x2e4a22, 0x22391f, 0x2f4d33].map(h => new THREE.Color(h));
const q = new THREE.Quaternion(), e = new THREE.Euler(), local = new THREE.Matrix4();

leafs.forEach((p, i) => {
  dum.position.set(p.x, groundHeight(p.x, p.z) - 0.06, p.z);
  dum.rotation.set(0, p.rot, 0);
  dum.scale.set(p.s, p.s * p.h, p.s);
  dum.updateMatrix();
  leafTrunks.setMatrixAt(i, dum.matrix);
  leafTrunks.setColorAt(i, cTmp.copy(trunkColA).lerp(trunkColB, Math.random()));
  const fc = leafCols[(Math.random() * leafCols.length) | 0].clone()
    .offsetHSL(rand(-0.02, 0.02), rand(-0.06, 0.06), rand(-0.02, 0.04));
  blobDefs.forEach((b, k) => {
    e.set(rand(0, 6.28), rand(0, 6.28), rand(0, 6.28));
    q.setFromEuler(e);
    local.compose(b.p, q, new THREE.Vector3(b.s, b.s * rand(0.8, 1.05), b.s));
    blobs.setMatrixAt(i * 3 + k, dum.matrix.clone().multiply(local));
    blobs.setColorAt(i * 3 + k, cTmp.copy(fc).multiplyScalar(rand(0.85, 1.1)));
  });
});
[leafTrunks, blobs].forEach(im => {
  im.instanceMatrix.needsUpdate = true;
  if (im.instanceColor) im.instanceColor.needsUpdate = true;
});

/* ================= the campfire ================= */
/* charred earth under the fire */
const pit = new THREE.Mesh(
  new THREE.CircleGeometry(1.02, 10),
  new THREE.MeshStandardMaterial({ color: 0x120b06, roughness: 1 })
);
pit.rotation.x = -Math.PI / 2;
pit.position.y = 0.012;
pit.receiveShadow = true;
scene.add(pit);

/* teepee of logs */
const charMat = new THREE.MeshStandardMaterial({ color: 0x241610, flatShading: true, roughness: 1 });
const teepeeGeo = new THREE.CylinderGeometry(0.07, 0.1, 1.55, 6);
teepeeGeo.rotateX(Math.PI / 2);
teepeeGeo.translate(0, 0, 0.77);
for (let i = 0; i < 5; i++){
  const a = (i / 5) * Math.PI * 2 + 0.35;
  const log = new THREE.Mesh(teepeeGeo, charMat);
  log.position.set(Math.cos(a) * 0.55, 0.1, Math.sin(a) * 0.55);
  log.lookAt(0, 1.3, 0);
  log.castShadow = true;
  scene.add(log);
}

/* glowing embers */
const emberMat = new THREE.MeshStandardMaterial({
  color: 0x1a0c06, emissive: 0xff5a1e, emissiveIntensity: 2, flatShading: true, roughness: 0.7
});
const emberGeo = new THREE.DodecahedronGeometry(1, 0);
for (let i = 0; i < 7; i++){
  const emb = new THREE.Mesh(emberGeo, emberMat);
  const a = rand(0, 6.28), r = Math.sqrt(Math.random()) * 0.45;
  const s = rand(0.07, 0.16);
  emb.position.set(Math.cos(a) * r, 0.05 + s * 0.4, Math.sin(a) * r);
  emb.scale.setScalar(s);
  emb.rotation.set(rand(0, 6.28), rand(0, 6.28), 0);
  scene.add(emb);
}

/* animated shader flames */
const FLAME_VERT = `
  uniform float uTime;
  uniform float uPhase;
  uniform float uHeight;
  varying float vH;
  float hash3(vec3 p){ return fract(sin(dot(p, vec3(12.9898, 78.233, 37.719))) * 43758.5453); }
  void main(){
    vec3 p = position;
    float h = clamp(p.y / uHeight, 0.0, 1.0);
    vH = h;
    float r = hash3(position);
    float t = uTime * 1.7 + uPhase * 7.0 + r * 6.2831;
    float sx = sin(t * 1.35 + uPhase) * 0.16 + sin(t * 6.1 + p.y * 5.0) * 0.11;
    float sz = cos(t * 1.15 + uPhase * 1.7) * 0.14 + cos(t * 5.3 + p.y * 4.3) * 0.11;
    p.x += sx * h * h;
    p.z += sz * h * h;
    p.y *= 1.0 + 0.07 * sin(t * 4.6 + uPhase);
    gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
  }`;
const FLAME_FRAG = `
  uniform vec3 uColA;
  uniform vec3 uColB;
  uniform float uIntensity;
  varying float vH;
  void main(){
    vec3 col = mix(uColA, uColB, smoothstep(0.05, 0.72, vH));
    float a = pow(1.0 - smoothstep(0.28, 1.0, vH), 1.3);
    gl_FragColor = vec4(col * uIntensity, a * 0.9);
    #include <colorspace_fragment>
  }`;

const flameGroup = new THREE.Group();
flameGroup.position.y = 0.14;
scene.add(flameGroup);
const flameMeshes = [];
function addFlame(r, h, phase, intensity, colA, colB){
  const geo = new THREE.ConeGeometry(r, h, 7, 4);
  geo.translate(0, h / 2, 0);
  const mesh = new THREE.Mesh(geo, new THREE.ShaderMaterial({
    transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide,
    uniforms: {
      uTime: uniTime,
      uPhase: { value: phase },
      uHeight: { value: h },
      uIntensity: { value: intensity },
      uColA: { value: new THREE.Color(colA) },
      uColB: { value: new THREE.Color(colB) }
    },
    vertexShader: FLAME_VERT,
    fragmentShader: FLAME_FRAG
  }));
  mesh.userData.phase = phase;
  flameGroup.add(mesh);
  flameMeshes.push(mesh);
}
addFlame(0.80, 0.62, 1.3, 1.15, 0xffa040, 0xc62b00);  // wide base tongue
addFlame(0.56, 1.80, 0.0, 1.60, 0xffc266, 0xff4400);  // main body
addFlame(0.40, 1.35, 2.4, 1.85, 0xffd28a, 0xff5a0a);  // secondary
addFlame(0.24, 1.00, 4.6, 2.50, 0xfff4c8, 0xffa020);  // hot core

/* soft fire glow sprite */
const glow = new THREE.Sprite(new THREE.SpriteMaterial({
  map: radialTex([[0, 'rgba(255,196,120,0.85)'], [0.22, 'rgba(255,150,55,0.34)'], [0.5, 'rgba(255,110,30,0.10)'], [1, 'rgba(255,90,20,0)']]),
  blending: THREE.AdditiveBlending, depthWrite: false, transparent: true, opacity: 0.3, fog: false
}));
glow.position.set(0, 1.0, 0);
glow.scale.set(5.7, 5.7, 1);
scene.add(glow);

/* ================= sparks (GPU particles) ================= */
const sparkGeo = new THREE.BufferGeometry();
{
  const N = 150;
  sparkGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(N * 3), 3));
  const seed = new Float32Array(N), speed = new Float32Array(N), angle = new Float32Array(N);
  for (let i = 0; i < N; i++){ seed[i] = Math.random(); speed[i] = rand(0.16, 0.5); angle[i] = rand(0, 6.28); }
  sparkGeo.setAttribute('aSeed', new THREE.BufferAttribute(seed, 1));
  sparkGeo.setAttribute('aSpeed', new THREE.BufferAttribute(speed, 1));
  sparkGeo.setAttribute('aAngle', new THREE.BufferAttribute(angle, 1));
}
const sparks = new THREE.Points(sparkGeo, new THREE.ShaderMaterial({
  transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
  uniforms: { uTime: uniTime, uScale: uniScale },
  vertexShader: `
    attribute float aSeed;
    attribute float aSpeed;
    attribute float aAngle;
    uniform float uTime;
    uniform float uScale;
    varying float vLife;
    void main(){
      float life = fract(uTime * aSpeed + aSeed);
      vLife = life;
      float rise = 2.0 + aSeed * 3.2;
      float r = 0.12 + life * (0.35 + fract(aSeed * 7.13) * 0.9);
      float ang = aAngle + life * (fract(aSeed * 3.71) - 0.5) * 4.0;
      vec3 p = vec3(cos(ang) * r, 0.22 + life * rise, sin(ang) * r);
      p.x += sin(life * 22.0 + aSeed * 40.0) * 0.09 * life;
      p.z += cos(life * 19.0 + aSeed * 34.0) * 0.09 * life;
      vec4 mv = modelViewMatrix * vec4(p, 1.0);
      float size = (0.05 + fract(aSeed * 13.7) * 0.03) * (1.0 - life * 0.72);
      gl_PointSize = max(size * uScale / -mv.z, 0.5);
      gl_Position = projectionMatrix * mv;
    }`,
  fragmentShader: `
    varying float vLife;
    void main(){
      vec2 uv = gl_PointCoord - 0.5;
      float d = length(uv) * 2.0;
      float a = (1.0 - smoothstep(0.1, 1.0, d));
      a *= smoothstep(0.0, 0.05, vLife) * (1.0 - smoothstep(0.55, 1.0, vLife));
      vec3 c = mix(vec3(1.0, 0.82, 0.42), vec3(1.0, 0.28, 0.04), smoothstep(0.1, 0.85, vLife));
      gl_FragColor = vec4(c * 1.7, a);
      #include <colorspace_fragment>
    }`
}));
sparks.frustumCulled = false;
scene.add(sparks);

/* ================= fireflies ================= */
const flyGeo = new THREE.BufferGeometry();
{
  const N = 26;
  const pos = new Float32Array(N * 3), seed = new Float32Array(N * 4);
  for (let i = 0; i < N; i++){
    const a = rand(0, 6.28), r = 3 + Math.sqrt(Math.random()) * 11;
    pos[i * 3] = Math.cos(a) * r;
    pos[i * 3 + 1] = rand(0.5, 3.0);
    pos[i * 3 + 2] = Math.sin(a) * r;
    seed[i * 4] = Math.random(); seed[i * 4 + 1] = Math.random();
    seed[i * 4 + 2] = Math.random(); seed[i * 4 + 3] = Math.random();
  }
  flyGeo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
  flyGeo.setAttribute('aSeed', new THREE.BufferAttribute(seed, 4));
}
const flies = new THREE.Points(flyGeo, new THREE.ShaderMaterial({
  transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
  uniforms: { uTime: uniTime, uScale: uniScale },
  vertexShader: `
    attribute vec4 aSeed;
    uniform float uTime;
    uniform float uScale;
    varying float vA;
    void main(){
      vec3 p = position;
      p.x += sin(uTime * (0.24 + aSeed.x * 0.3) + aSeed.y * 6.28) * (0.9 + aSeed.z * 1.4);
      p.y += sin(uTime * (0.19 + aSeed.y * 0.33) + aSeed.x * 6.28) * 0.55;
      p.z += cos(uTime * (0.21 + aSeed.z * 0.27) + aSeed.w * 6.28) * (0.9 + aSeed.y * 1.4);
      float blink = pow(max(sin(uTime * (0.5 + aSeed.w * 1.1) + aSeed.y * 6.28), 0.0), 3.0);
      vA = blink;
      vec4 mv = modelViewMatrix * vec4(p, 1.0);
      gl_PointSize = (0.045 + 0.045 * blink) * uScale / -mv.z;
      gl_Position = projectionMatrix * mv;
    }`,
  fragmentShader: `
    varying float vA;
    void main(){
      vec2 uv = gl_PointCoord - 0.5;
      float d = length(uv) * 2.0;
      float a = (1.0 - smoothstep(0.15, 1.0, d)) * vA;
      gl_FragColor = vec4(vec3(0.72, 0.95, 0.35) * 1.5, a);
      #include <colorspace_fragment>
    }`
}));
flies.frustumCulled = false;
scene.add(flies);

/* ================= smoke wisps ================= */
const smokeTex = radialTex([[0, 'rgba(200,192,182,0.42)'], [0.55, 'rgba(160,152,146,0.14)'], [1, 'rgba(120,114,110,0)']]);
const smokes = [];
for (let i = 0; i < 7; i++){
  const m = new THREE.SpriteMaterial({ map: smokeTex, transparent: true, depthWrite: false, opacity: 0, fog: false, rotation: rand(0, 6.28) });
  const s = new THREE.Sprite(m);
  s.userData = { seed: Math.random(), speed: rand(0.055, 0.1), dx: rand(-0.35, 0.35), dz: rand(-0.35, 0.35), spin: rand(-0.25, 0.25) };
  scene.add(s);
  smokes.push(s);
}

/* ================= resize ================= */
function onResize(){
  const w = innerWidth, h = innerHeight;
  camera.aspect = w / h;
  camera.updateProjectionMatrix();
  renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
  renderer.setSize(w, h);
  const pr = renderer.getPixelRatio();
  uniPR.value = pr;
  uniScale.value = (h * pr) / (2 * Math.tan(THREE.MathUtils.degToRad(camera.fov * 0.5)));
}
addEventListener('resize', onResize);
onResize();

/* ================= animation loop ================= */
const clock = new THREE.Clock();
const loadingEl = document.getElementById('loading');
const hintEl = document.getElementById('hint');
let booted = false, touched = false;

controls.addEventListener('start', () => {
  controls.autoRotate = false;
  if (!touched){ touched = true; hintEl.classList.add('hide'); }
});

renderer.setAnimationLoop(() => {
  const dt = Math.min(clock.getDelta(), 0.05);
  const t = clock.elapsedTime;
  uniTime.value = t;

  /* fire flicker: layered incommensurate sines */
  const fl = Math.sin(t * 8.7) * 0.5 + Math.sin(t * 22.3 + 1.7) * 0.22
           + Math.sin(t * 3.9 + 0.4) * 0.28 + Math.sin(t * 14.1 + 2.9) * 0.18;
  fireLight.intensity = 230 * (1 + 0.13 * fl);
  fireLight.position.set(Math.sin(t * 13.7) * 0.08, 1.25 + Math.sin(t * 9.1) * 0.05, Math.cos(t * 11.9) * 0.08);
  emberMat.emissiveIntensity = 1.8 + fl * 0.9;
  glow.material.opacity = 0.28 + fl * 0.055;
  const gs = 5.7 + fl * 0.55;
  glow.scale.set(gs, gs, 1);

  for (const f of flameMeshes){
    const ph = f.userData.phase;
    f.rotation.y += dt * 0.7;
    const b = Math.sin(t * 9.1 + ph);
    f.scale.set(1 - b * 0.05, 1 + b * 0.075, 1 - Math.cos(t * 7.7 + ph) * 0.05);
  }

  for (const s of smokes){
    const u = s.userData;
    const life = (t * u.speed + u.seed) % 1;
    s.position.set(
      u.dx + life * 0.9 + Math.sin(t * 0.4 + u.seed * 9.0) * 0.25,
      0.75 + life * 5.3,
      u.dz + life * 0.7
    );
    const sc = 0.7 + life * 3.6;
    s.scale.set(sc, sc, 1);
    s.material.opacity = Math.sin(life * Math.PI) * 0.085;
    s.material.rotation += dt * u.spin;
  }

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

  if (!booted){
    booted = true;
    loadingEl.style.opacity = '0';
    setTimeout(() => loadingEl.remove(), 800);
  }
});
</script>
</body>
</html>
Reasoning trace (69,188 tokens)
Let me plan this carefully. I need to build a single HTML file with Three.js 0.170.0 loaded via the specified import map. Requirements:

1. Ground with low-poly rocks and logs around a central campfire
2. At least 30 low-poly trees placed randomly around the clearing (not on the fire)
3. Campfire with animated flames and warm flickering point light with shadows
4. Sparks rising from fire (particle system)
5. Dark night sky with stars and moon, fog fading distant trees
6. OrbitControls with limits keeping camera above ground
7. Renderer fills window, handles resize
8. Also: self-contained, no errors, must look good in screenshot at ~3 seconds, responsive down to 375px, no horizontal scrollbar.

Let me design the scene:

**Scene setup:**
- Renderer: WebGL, shadows enabled (PCFSoftShadowMap), tone mapping ACESFilmic, sRGB output (default in newer three: outputColorSpace = SRGBColorSpace, which is default).
- Camera: perspective, positioned at like (0, 6, 14) looking at fire.
- Fog: `new THREE.FogExp2(0x070a12, 0.022)` or linear fog. Dark blue-black color. Sky background matching fog color, or a gradient. I could use a large sphere with vertex colors for a subtle gradient, or just scene.background = color. Let's do a subtle gradient sky using a big sphere with a shader or vertex colors. Simpler: scene.background = new THREE.Color(0x050810) and fog same color. Maybe add a subtle gradient dome via ShaderMaterial. Let me do a custom shader sky dome — it's not too hard:

```js
const skyGeo = new THREE.SphereGeometry(300, 24, 16);
const skyMat = new THREE.ShaderMaterial({
  side: THREE.BackSide,
  uniforms: { top: {value: new THREE.Color(0x0a1024)}, bottom: {value: new THREE.Color(0x02040a)} },
  vertexShader: `varying vec3 vPos; void main(){ vPos = position; gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0);}`,
  fragmentShader: `varying vec3 vPos; uniform vec3 top; uniform vec3 bottom; void main(){ float h = normalize(vPos).y*0.5+0.5; gl_FragColor = vec4(mix(bottom, top, pow(max(h,0.0),0.6)),1.0);}`
});
```

Fog won't affect ShaderMaterial unless I add fog support. But sky should be beyond fog anyway; fog on the sky sphere would wash it out. Actually with FogExp2, distant objects get fully fogged to fog color. If sky sphere is at radius 300 with density 0.022, it would be fully fog color — that's fine if sky bottom matches fog color, but stars/moon need to be visible. Better: make stars/moon materials with `fog: false`. Points material and sprite materials support `fog` property. Yes, set `fog: false` on star material and moon material so they render through fog.

Alternatively use scene.fog = new THREE.Fog(color, near, far) with near ~ 20, far ~ 90 — trees at 25-45 radius would be partially faded. Stars at radius ~200 would be beyond far so fully fogged unless fog:false. Set fog: false on those materials. Good.

**Ground:**
- CircleGeometry or PlaneGeometry with vertex displacement for low-poly terrain. Use PlaneGeometry(size, size, segments, segments), displace vertices with noise (small), then `geometry.computeVertexNormals()`. Use flat shading for low-poly look. Color: dark green-brown, maybe vertex colors for variation. Use MeshStandardMaterial with flatShading: true, color like 0x1a2b1a or with slight variation via vertex colors.

Let me make ground radius ~60. Plane 120x120 with 40x40 segments. Displace: y = noise-ish. Keep near center flat-ish (dampen displacement near center so fire sits flat). Use simple pseudo-random noise: sum of sines: `Math.sin(x*0.3)*Math.cos(z*0.25)*0.4 + ...` plus random jitter per vertex for low-poly facets. But random per-vertex jitter with shared vertices in plane geometry: PlaneGeometry is non-indexed? Actually PlaneGeometry is indexed; vertices shared. For flat shading look, better to call `geometry = geometry.toNonIndexed()` then displace each triangle vertex — hmm, that would tear the mesh. Instead displace based on position (deterministic function of x,z), which keeps mesh watertight, then toNonIndexed + computeVertexNormals gives flat facets. Actually flatShading: true on material handles flat shading without needing non-indexed — MeshStandardMaterial with flatShading computes flat normals in shader? No — flat shading in three.js is done via `flatShading: true` which uses derivative-based normals in shader (it computes face normal via dFdx/dFdy). Yes, in modern three.js flatShading works in the shader, so indexed geometry is fine.

So: displace vertices with deterministic noise, keep flatShading: true.

Add vertex colors for variation: darker patches, moss. Could do: base color 0x24301c... Let me pick a palette:
- Ground: dark mossy green-brown, like #1e2a1e base with variation.
- Firelight will warm it up near center.

**Rocks:** IcosahedronGeometry(radius, 0) with random vertex jitter, scaled non-uniformly, flat shading, gray color 0x4a4f55. Place 6-9 rocks around fire at radius 3-8, random rotations. Cast/receive shadows.

**Logs:** CylinderGeometry with 5-6 sides, brown 0x4a3324, laid as fallen logs near clearing, plus a few log segments in the fire itself (crossed teepee style). Requirements say "a few low-poly rocks and logs around a campfire" — so logs around the fire. I'll do: a stone fire ring (small rocks in a circle), 2-3 fallen logs in the scene, plus crossed logs in the fire itself.

**Campfire:**
- Stone ring: ~8 small icosahedron rocks in circle radius ~1.1.
- Logs in fire: 4-5 cylinders leaning teepee style, charred dark color, glowing embers at base.
- Flames: animated low-poly flames. Approach: several cone/tetrahedron meshes with custom shader that animates flicker, or use vertex-displaced cones. A common good-looking approach: 3-5 cones of varying heights with a shader that offsets vertices with noise based on time and height, additive blending, colors from yellow base to orange to red tip.

Let me write a flame shader:

Vertex shader: displace x,z by sin(time + y*freq)*amp*(1 - y normalized) to make it waver more at top. Fragment: color gradient by height: mix from bright yellow (0xffdd66) at bottom to orange (0xff6a00) to red at top; alpha fades near tip. Additive blending, depthWrite false.

Alternatively simpler robust approach: use MeshBasicMaterial cones with additive blending and animate scale/rotation in JS each frame — that can look decent but shader looks better. Let me do shader-based flames with 2-3 cones at different scales/offsets, plus an inner bright core.

Flame shader:

```glsl
// vertex
uniform float uTime;
varying float vY;
varying float vRand;
attribute float aRand; // per mesh? Could use position hash
void main(){
  vec3 p = position;
  float h = clamp(p.y / 1.6, 0.0, 1.0); // depends on cone height
  float wobble = sin(uTime*6.0 + p.y*4.0 + aRand*10.0) * 0.12 * h;
  p.x += wobble;
  p.z += cos(uTime*5.0 + p.y*3.0 + aRand*7.0) * 0.1 * h;
  vY = h;
  vRand = aRand;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(p,1.0);
}
// fragment
varying float vY;
uniform float uTime;
void main(){
  vec3 c = mix(vec3(1.0,0.85,0.35), vec3(1.0,0.35,0.05), smoothstep(0.0,0.7,vY));
  c = mix(c, vec3(0.9,0.1,0.0), smoothstep(0.7,1.0,vY));
  float a = (1.0 - smoothstep(0.55, 1.0, vY)) ;
  a *= 0.9;
  gl_FragColor = vec4(c*1.8, a);
}
```

With AdditiveBlending, depthWrite false, transparent true, side: DoubleSide.

Also animate the mesh scale slightly per-frame (breathing) and rotate slowly.

Embers at base: a small glowing mesh — e.g., a dodecahedron cluster with emissive orange material pulsing. Use MeshStandardMaterial emissive 0xff4400, emissiveIntensity pulsing.

**Point light:** THREE.PointLight(0xff8833 or warm 0xffa050, intensity ~ 60 (with physically correct falloff in r155+, intensity is in candela and decay 2 default). distance ~ 30, castShadow true, shadow.mapSize 1024, bias -0.005. Flicker: intensity = base + noise(t) — combine sines + random smoothing. Position at (0, 1.2, 0) or slightly above flames, jitter position slightly.

In three r155+, `useLegacyLights` default false, so point light intensity needs high values with decay 2. PointLight intensity in physical mode: intensity is candela; at distance d, illuminance = intensity/d². For visible warm glow reaching trees ~10 units away, intensity ~ 150-250? Let's compute: at d=5, intensity 200 → 200/25 = 8 lux-ish... Actually renderer tone mapping, exposure 1. Standard examples: pointLight intensity 100 with decay 2 lights up a few meters nicely. Let me use intensity ~ 180 base with distance 40. I'll tune: base intensity 150, flicker ±40. Flicker function: `base * (0.85 + 0.15 * (sin(t*11)*0.5 + sin(t*17+1.3)*0.3 + sin(t*5.7)*0.2) + small random)`.

Also add subtle ambient: hemisphere light very dim bluish (moonlight) — HemisphereLight(0x223355, 0x0a0a12, 0.35) or a dim directional "moonlight" bluish that also casts soft shadows? A directional moonlight from the moon's direction, low intensity 0.15 bluish, castShadow maybe with a large shadow camera — that adds nice moon shadows of trees. But two shadow-casting lights doubles cost; fine for a demo. Actually moon shadows across the ground from trees would look great. Let's add DirectionalLight(0x8899cc, 0.25) positioned at moon direction (e.g., (20, 30, -15)), castShadow true, shadow camera covering -40..40, mapSize 2048. Hmm, might be heavy but okay. Let me include it with 1024 or 2048. I'll do 2048 for crispness; performance should be fine for a static scene.

**Sparks:** THREE.Points with BufferGeometry, ~120 particles. Each particle: random seed, respawn at fire base with random velocity upward + spread. Animate in JS each frame updating positions attribute, or in shader with time-based loop (better perf). Shader approach: attributes: aSeed, aSpeed, aOffset. In vertex shader compute life = fract(uTime*aSpeed + aSeed), position = start + vel*life, size shrinks, alpha = (1-life). Color: bright orange-yellow, additive blending.

Let me do GPU shader sparks:

```glsl
attribute float aSeed;
attribute float aSpeed;
attribute float aAngle;
uniform float uTime;
varying float vLife;
void main(){
  float life = fract(uTime * aSpeed + aSeed);
  vLife = life;
  float h = life * 3.5; // rise height
  float ang = aAngle + life * 2.0 * (aSeed - 0.5);
  float r = 0.15 + life * 0.6 * (0.5 + aSeed*0.5);
  vec3 p = vec3(cos(ang)*r, h, sin(ang)*r);
  // wobble
  p.x += sin(life*20.0 + aSeed*40.0)*0.05*life;
  p.z += cos(life*17.0 + aSeed*30.0)*0.05*life;
  vec4 mv = modelViewMatrix * vec4(p, 1.0);
  gl_PointSize = (2.0 + 2.5*(1.0-life)) * (60.0 / -mv.z);
  gl_Position = projectionMatrix * mv;
}
```

fragment: circular point sprite, color mix yellow→red by life, alpha (1-life) with smooth edge:

```glsl
varying float vLife;
void main(){
  vec2 uv = gl_PointCoord - 0.5;
  float d = length(uv);
  if (d > 0.5) discard;
  float a = smoothstep(0.5, 0.0, d) * (1.0 - vLife);
  vec3 c = mix(vec3(1.0, 0.9, 0.5), vec3(1.0, 0.3, 0.0), vLife);
  gl_FragColor = vec4(c * 2.0, a);
}
```

Additive blending, depthWrite false, transparent.

**Trees:** ≥30 low-poly trees. Two types: pine (cone stacks) and deciduous (icosahedron blobs). Pine: trunk cylinder (5-sided) + 2-3 cones stacked, dark green 0x14301c-ish with slight hue variation. Low-poly: ConeGeometry(r, h, 6 segments). Place randomly in annulus radius 12..48, min distance apart maybe (or allow overlap slightly, it's fine—random forest). Avoid fire: all placements at r > 12 from center. Also avoid camera near path? Camera orbits; keep clearing radius 12 clear.

Tree count: 40-55. Random scale 0.7-1.4. Cast shadows — 50 trees × few meshes each with shadow casting; the directional shadow map and point light shadows will render all — fine.

Use InstancedMesh for performance? With ~50 trees it's fine with regular meshes, but instancing is cleaner: trunks instanced, cone layers instanced per level. Simpler: create merged geometry per tree type? Simplest reliable: build each tree as a Group of meshes — 50 trees × 4 meshes = 200 draw calls; plus shadow passes... could be 600 draws total per frame with 2 shadow lights. At 60fps that's okay for modern hardware but let's be smarter: use InstancedMesh for trunks and foliage cones.

Plan: 
- Pine trunks: CylinderGeometry(0.12, 0.18, 1.2, 5) → InstancedMesh count = nPines, each instance positioned/scaled/rotated.
- Pine foliage: 3 cone layers, each layer its own InstancedMesh with per-instance matrices (scaled/offset relative to trunk).
- Deciduous: trunk cylinder + icosahedron blob(s) 2 per tree.

That gives ~8 draw calls for all trees. 

Per-instance color variation: InstancedMesh supports setColorAt. Use setColorAt with slight variation of base color. Nice.

Matrices: use dummy Object3D, set position/rotation/scale, updateMatrix, setMatrixAt.

**Stars:** THREE.Points with ~800 stars on upper hemisphere of radius ~250 (random direction with y>0.05), sizes random, white-blue colors, PointsMaterial size ~1.2, sizeAttenuation false? With sizeAttenuation true at radius 250 they'd be tiny; better use custom shader or sizeAttenuation:false with size in pixels (e.g., 1.5-2.5). Use PointsMaterial({size: 2, sizeAttenuation: false, fog: false, transparent: true, opacity 0.9, vertexColors: true}). Vary star brightness via colors. Maybe twinkle: custom shader with time-based alpha per star via attribute seed. Twinkling adds life. Let me do a small custom shader for stars too, or simpler: PointsMaterial and skip twinkle... Twinkle is a nice touch; I'll write a tiny ShaderMaterial for stars with aSeed attribute and uTime, alpha = 0.5+0.5*sin(t*speed+seed), plus a few brighter stars.

Also a subtle "milky way" band? Overkill. Keep it elegant: stars + moon.

**Moon:** A circle sprite/mesh: use a SphereGeometry small? For a stylized low-poly moon: IcosahedronGeometry(6, 1) with flat shading, MeshBasicMaterial pale #dfe8ff slightly emissive-looking (basic is unlit so it appears bright), positioned far (e.g., (-60, 80, -90)) direction matching moonlight directional light. fog: false on material. Add a soft glow: a Sprite with radial gradient canvas texture, additive, behind the moon. Canvas texture: draw radial gradient white→transparent. That's allowed (canvas-generated texture, no external files). Good.

Also add a few clouds? No, keep clean.

**OrbitControls:** from 'three/addons/controls/OrbitControls.js'. Settings: enableDamping true, dampingFactor 0.05, minDistance 4, maxDistance 30, maxPolarAngle = Math.PI/2 - 0.05 (keeps camera above ground — actually to keep above ground strictly, maxPolarAngle slightly less than PI/2, like 1.45 rad ≈ 83°). Also target at (0, 1, 0) (fire height). minPolarAngle maybe 0.15 to avoid top-down weirdness? Allow some. autoRotate? Maybe gentle autoRotate: true, autoRotateSpeed 0.4 for the initial demo so scene shows motion — nice for screenshot judging? Screenshot is static anyway; autorotate makes the live demo lively. I'll enable autoRotate slow (0.5) and stop it on user interaction (controls 'start' event). That's a nice touch.

**Overlay UI:** Title text "Night Camp" + hint "Drag to look around · Scroll to zoom". Style with elegant font (no external fonts — use Georgia/serif or system stack). Since no external fonts allowed... The rules say no external files/fonts; must use system fonts. Use a nice stack: `font-family: Georgia, 'Times New Roman', serif` for title, or letter-spaced uppercase sans. I'll make a minimal overlay: small title top-left, hint bottom-center fading out after interaction. Also maybe a loading state — importmap module loads fast from CDN.

Vignette: CSS radial gradient overlay on top of canvas for cinematic feel — subtle, pointer-events none. Nice.

**Resize:** window resize listener updating camera aspect + renderer size + pixel ratio clamp (min(devicePixelRatio, 2)).

**Ground contact:** ground at y=0 with slight displacement; ensure objects placed on terrain height. To keep it simple, make displacement small (±0.35) and dampen near center; trees placed with y = terrainHeight(x,z) computed from the same function, so they sit on ground. I'll write `groundHeight(x,z)` deterministic function used both for geometry displacement and object placement. 

groundHeight(x,z): 
```js
function groundHeight(x, z){
  const d = Math.hypot(x, z);
  const flat = THREE.MathUtils.smoothstep(d, 2, 10); // 0 near center → 1 far
  let h = Math.sin(x*0.16)*Math.cos(z*0.19)*0.9
        + Math.sin(x*0.42 + 1.7)*Math.cos(z*0.35 + 0.6)*0.45;
  h += Math.sin(x*0.9)*Math.sin(z*0.8)*0.15;
  return h * flat;
}
```
Hmm plus maybe a gentle bowl? Keep it gentle hills. Also raise distant rim? Not needed; fog hides edge. Ground plane 130×130 so edges are far beyond fog far (~90).

Wait — fog far 90 with camera at ~14 from center: trees up to radius 48 get faded nicely; ground extends to 65 radius corners 92; edges fully fogged. Good.

Actually let me reconsider fog: linear Fog(0x0a0f1e? , near 18, far 95). Sky dome bottom color should match fog color for seamlessness at horizon. Stars have fog:false so visible. Sky shader also fog:false (ShaderMaterial ignores fog by default). 

Sky colors: bottom #0a0f1e (fog color), top #030510? Hmm, usually sky gradient is darker at zenith... At night: horizon slightly lighter (moon glow), zenith darker deep blue. Let me do bottom #101830, top #02040c. Fog color pick something between: fog color should match what distant trees blend into — the horizon color, so fog color = #0d1526-ish. I'll set fog color to 0x0d1526 and sky bottom similar #0d1526 blending up to #030614 zenith.

Hmm, but the moon adds glow — I could add glow into sky shader near moon direction. Nice touch: fragment shader mixes in a soft radial glow around moon direction. Let me do:

```glsl
uniform vec3 uMoonDir;
float m = max(dot(normalize(vPos), uMoonDir), 0.0);
col += vec3(0.35,0.45,0.6) * pow(m, 24.0) * 0.35; // halo
```

That's elegant. Moon mesh positioned along uMoonDir at distance ~180, with sprite glow.

**Fire logs (teepee):** 5 cylinders radius 0.09, length 1.4, hexagonal, positioned leaning inward: bottom at radius 0.55, top at radius 0.1, tilted. Color dark brown/charred #2a1c12. Plus embers cluster: a few small dodecahedrons at center with emissive orange pulsing. Also the stone ring: 9 rocks radius ~0.28 scale, around radius 1.15.

**Extra scenery for composition:** 
- A bedroll/tent? A small low-poly tent near the fire would add narrative! A tent: two planes leaning + back triangle, or a prism: use a custom geometry — a triangular prism tent with warm dark canvas color 0x5a3d2b, plus maybe a tiny warm light inside? Might complicate. A tent is a lovely focal addition. Let me build a simple A-frame tent: BufferGeometry with vertices, or easier: two BoxGeometry thin planes rotated, plus ends via triangle ShapeGeometry. Alternative: use ConeGeometry with 4 segments cut? A 4-sided cone rotated 45°... Actually classic low-poly tent: a triangular prism. Build via ExtrudeGeometry of a triangle shape along z? ExtrudeGeometry(triangleShape, {depth: 2.2, bevelEnabled: false}) → prism along z. Triangle: (-1,0), (1,0), (0,1.4). Then material flat canvas color, position near fire at like (4.5, h, 2.5), rotated to face fire. Add a dark opening: a smaller triangle on front with near-black color, or an emissive warm inner glow plane slightly inside → makes it look cozy with firelight. Simple: put a small warm PointLight? No, too much. Just a dark opening triangle (MeshBasicMaterial 0x0a0806). Hmm, actually a subtle warm emissive opening would suggest someone's inside... Keep opening dark; firelight from main point light will hit tent exterior. Good enough. Also a log bench: two short log segments near fire to sit on — "logs around a campfire" requirement satisfied: place 2-3 fallen logs (cylinders) lying on ground around fire + the log benches. 

Requirement: "A ground with a few low-poly rocks and logs around a campfire in the center." So rocks and logs must be around the campfire — I'll scatter 3 fallen logs within radius 4-10 and 6-8 rocks radius 2.5-9, plus the fire ring stones. Good.

**Shadows:** point light castShadow with mapSize 1024; shadow.camera.near 0.1 far 40. Note: PointLight shadows use cube shadow map — 6 renders of all casters. With instanced trees this is fine. Directional light also castShadow: 2048 map, ortho bounds ±45. Both — cost is okay.

Hmm, one concern: point light shadow acne on ground — set bias: -0.01? For point light, shadow.bias = -0.005, maybe normalBias 0.02. I'll tune typical values: bias -0.005.

Actually, to be safe with performance & quality, maybe skip directional shadow casting and rely on point light shadows + dim ambient hemisphere? Moon shadows add depth... Let me keep directional with castShadow true; the scene is static so it's fine.

Also ambient: THREE.AmbientLight(0x1a2338, 0.5)? With hemisphere: HemisphereLight(0x2b3a5e sky, 0x0e0a08 ground, 0.45). Plus AmbientLight small. I'll use hemisphere 0.5 and see. Since I can't visually iterate, I'll pick values from experience: night scene with ACES tonemapping, exposure ~1.1. Hemisphere (0x334466, 0x140f0a, 0.6). Directional moon 0x9db4e8, intensity 0.35. Fire point light 0xff9540, intensity base 200, distance 45, decay 2 (default). Hmm decay default is 2. distance 45 cutoff.

Let me double check point light intensity scale in r170 physical lighting: renderer.useLegacyLights removed (r155+ default physical). PointLight intensity = candela. Ground at 2 units from light: E = 200/4 = 50 → quite bright with ACES. Trees at 15: 200/225 ≈ 0.9 — visible warm tint. Good. Maybe base 260 flickering ±15%.

Flicker implementation:
```js
function flicker(t){
  return 0.82 + 0.18*Math.sin(t*11.3) * 0.5 + ... 
}
```
Better: layered sines:
```js
const n = Math.sin(t*9.7)*0.5 + Math.sin(t*23.1+1.2)*0.25 + Math.sin(t*4.3+3.1)*0.25;
light.intensity = 240 * (1 + 0.12*n);
light.position.x = Math.sin(t*13.2)*0.06;
light.position.z = Math.cos(t*11.7)*0.06;
light.position.y = 1.1 + Math.sin(t*17.3)*0.03;
```
Also small random walk component via smoothing: keep a target random changed every ~120ms lerped. Sines alone look decent. I'll combine sines + slow random drift.

**Flames animation details:** 
Flame group at fire center y≈0.15. Cones: 
- main: ConeGeometry(0.5, 1.6, 6, 3 height segments) open ended? Cone with 6 radial segs, 4 height segs for vertex wobble. 
- second: smaller, rotated, offset, slightly different phase.
- core: small bright cone.
Each flame material: uniforms uTime, uPhase, uHeight (for normalization), colors.

Actually to make normalization robust, compute vY = position.y / uHeight in shader where geometry is a cone with base at y=0: ConeGeometry centers at origin (from -h/2 to h/2). I'll translate geometry up by h/2 so base at 0: `geo.translate(0, h/2, 0)`.

Vertex wobble in shader needs position before model matrix — fine, displacement in object space, then modelViewMatrix. Wobble amount scaled by uHeight.

Also stretch: scale y pulsing via mesh.scale.y = 1 + 0.08*sin(t*10+phase). And rotation.y slow.

Fragment: 
```glsl
float g = vY; // 0..1
vec3 c1 = vec3(1.0, 0.92, 0.55); // bright core yellow-white
vec3 c2 = vec3(1.0, 0.45, 0.08); // orange
vec3 c3 = vec3(0.85, 0.12, 0.02); // red tip
vec3 col = mix(c1, c2, smoothstep(0.05, 0.45, g));
col = mix(col, c3, smoothstep(0.5, 0.95, g));
float a = (1.0 - smoothstep(0.45, 1.0, g)) * 0.85;
// edge softness using normal? skip
gl_FragColor = vec4(col * uIntensity, a);
```
Additive blending — overlapping cones brighten center. Nice.

Also inner glow at fire base: add a small icosahedron with emissive or a MeshBasicMaterial orange, flattened, additive? Add "glowing coals": 5-7 small tetrahedrons around fire center with MeshStandardMaterial({color:0x1a0e08, emissive:0xff3300, emissiveIntensity: pulsing 1-2.5, flatShading}). Pulses with flicker — pass flicker value to embers emissiveIntensity. 

**Smoke?** Not required; sparks suffice. Maybe a faint smoke via a few big transparent sprites rising slowly — adds atmosphere. Could do shader points with soft texture, gray, additive won't work for smoke (needs normal blending with low opacity). Simple: 10 sprites with canvas radial texture, grayish, opacity 0.05-0.12, rising & expanding, respawn. It's a nice touch and cheap. Let me add subtle smoke — using the same canvas glow texture but colored dark gray with normal blending, depthWrite false. Risk: could look bad if too strong; keep opacity very low (0.06-0.1) and size growing 1→4. I'll include it, carefully subtle.

Hmm, smoke at night lit by fire underneath would be faintly visible. Alpha 0.08 normal blending, color #4a4038... okay.

**Canvas textures** (radial gradients) for: spark? sparks are shader points (no texture). Moon glow sprite: radial gradient white→transparent. Smoke: radial gradient rgba gray→transparent. Both generated locally. Fine.

**Grass tufts?** Low-poly grass blades (small cones/planes) scattered near clearing — adds richness. Could use instanced small tetrahedrons... Might clutter; skip or add tiny "grass" as small dark green cones scattered — meh, skip. Instead maybe a few stumps/mushrooms? Keep scope controlled: rocks, logs, trees, tent, fire, stars, moon. Maybe add fireflies! Tiny drifting yellow-green glowing points in the clearing — magical at night, cheap (another Points with shader, slow sinusoidal paths, additive). That would elevate the scene. Yes, ~25 fireflies, gentle drift, pulsing alpha. 

**Composition for screenshot:** camera initial position (7.5, 4.2, 12), target (0, 1.2, 0). Fire centered slightly below middle, trees ring around, moon upper-left in sky. Moon at direction (-0.45, 0.6, -0.66) normalized * 180. Ensure moon visible in initial view: camera looks from (7.5,4.2,12) toward center, so view direction is toward (-0.51,-0.2,-0.83)-ish → sky visible beyond center is at -x,-z, up. Moon at (-70, 95, -110) normalized dir ≈ (-0.44, 0.6, -0.65)... that's up-left in view. It should appear in upper part of frame. Good.

Wait, with maxDistance 30 and initial distance ~14.6 — fine.

Camera FOV 55. On 1280×800 fine; portrait 375px — aspect handled.

**Ground coloring with vertex colors:** Use MeshStandardMaterial({vertexColors: true, flatShading: true, roughness: 1}). Compute per-vertex color: base moss green #22321f varied with noise: mix between #1c2a1c and #31402a? Also a dirt patch near fire center: within radius 2.5, blend to brown #3a2b1c (trampled earth). That's a nice detail: `dirtMix = 1 - smoothstep(1.5, 3.5, d)`.

Also add slight bluish tint far away? Fog handles it.

**Rocks placement:** avoid fire (d>1.8), random radius 2.5-10, on ground height. Sizes 0.25-0.9. Random rotation. Color variation gray #566069..#3d444c via setColorAt? Regular meshes: per-mesh material color variation or shared material. Use shared material, vary via 3 materials? Simple: create 8 rocks each with own material clone color slightly varied — cheap (8 materials fine).

**Logs:** fallen logs: cylinder radius 0.18-0.3, length 2.2-3.5, 7-sided, lying: rotation.z = PI/2 plus random yaw, y = groundHeight + radius*0.8. End caps: cylinder ends visible — fine low-poly. Color #3a2a1c bark. Add slight color variation. Maybe a couple of short "sitting logs" near fire at radius 2.2 (bench logs) horizontally facing fire — yes: 2 logs parallel, like benches. Cute.

**Tent:** described above. ExtrudeGeometry triangle prism. Let me define:

```js
const shape = new THREE.Shape();
shape.moveTo(-1.1, 0); shape.lineTo(1.1, 0); shape.lineTo(0, 1.5); shape.closePath();
const geo = new THREE.ExtrudeGeometry(shape, { depth: 2.4, bevelEnabled: false });
geo.translate(0, 0, -1.2); // center along z
```
Material: MeshStandardMaterial color 0x6b4a33? Canvas tent at night — desaturated brown/tan #7a5c42. flatShading true. Position at (5.2, groundHeight, -3.4), rotate y to face fire (rotation.y = Math.atan2(...) + offset). Front opening: dark triangle slightly in front of front face: a ShapeGeometry triangle scaled 0.75, positioned at front face z = +1.21? Since extrude along +z from 0 to 2.4 then translated -1.2 → front face at z=1.2 (local). Put dark triangle mesh at z=1.21, y=0.02, rotated to face outward (ShapeGeometry faces +z by default — good). color 0x0c0906. Also small warm glow inside? The dark opening with slight orange at bottom edge — skip, keep dark.

Also maybe a sleeping bag? no.

Also "firewood pile" next to fire: few small cylinders stacked — nice detail: 4-5 cylinders stacked horizontally in a pile near tent. Sure, quick with instanced or plain meshes.

**Trees placement details:**
- N = 46 trees total: ~34 pines, ~12 deciduous? Requirement ≥30. Use random radius 13..46 (sqrt-distributed for uniform area), angle random. Also avoid a corridor? Camera orbits at up to 30 distance; trees at 13+ won't block initial 14.6 view... camera at (7.5,4.2,12): its distance 14.6 with trees at ≥13 could occasionally block view. Set tree min radius 15 to be safe, and camera maxDistance 26. Trees 15..48. Hmm "clearing" — clearing radius 15 is big but fine, or keep trees at 12+ but ensure none near camera start line... simpler: min 14, maxDistance 26, initial camera distance 15 → hmm camera at distance 15 could be inside tree zone. Let me set tree min radius 16, initial camera distance ~13, maxDistance 24. Trees 16-48. Good: 50 trees.

Overlap check: ensure trees not too close to each other: rejection sampling min dist 2.2 between trees. With radius 16-48 area huge, 50 trees easily fit.

Tree geometry (pine):
- trunk: CylinderGeometry(0.14, 0.22, 1.6, 5) — instanced; per tree scaled.
- foliage: three cones: bottom ConeGeometry(1.15, 1.5, 7), mid (0.85, 1.3, 7), top (0.55, 1.1, 7) stacked with overlap. Instanced per level. Colors per instance: dark pine green base #17351f varied ±.

Instanced approach: for each level, geometry fixed, per-instance matrix composed from tree transform × level offset. Since tree scale uniform s and rotation y, I can compose: level matrix = translate(0, levelY*s, 0) * scale? Actually cone levels scale with tree scale too. Compose using dummy: dummy.position.set(x, y + levelY*s, z); dummy.scale.set(s*(1+var), s, s*(1+var)); dummy.rotation.y = treeRot; updateMatrix; setMatrixAt. Fine.

Deciduous: trunk cylinder(0.16,0.24,1.8,5) + blob IcosahedronGeometry(1.1, 0) at y 2.2 scale (1, 0.9, 1) + smaller blob offset. Color: dark desaturated green #2a3a22 with variation, maybe some slightly yellow-green #3a4a26. Instanced icosahedra: two blobs per tree → 2 instanced meshes or one with 2× instances. Use one InstancedMesh with count = 2*nDecid.

setColorAt requires instanceColor; call after setting, set `instanceColor.needsUpdate = true`... setColorAt handles creation. Also material must have vertexColors? No — instanceColor works with any material color multiplied? InstancedMesh color via instanceColor multiplies material color in shader when USE_INSTANCING_COLOR. Set material color white and put actual color per instance, or material color base and per-instance tint. I'll set material color to white and use per-instance colors directly.

Shadows on instanced meshes: castShadow = true works for InstancedMesh. receiveShadow true for trunks? Foliage receiveShadow true adds self-shadow looks — point light shadow through foliage... fine, enable receiveShadow on foliage too (with flat shading, shadow acne risk on cones — bias tuned; or set foliage receiveShadow false to be safe). I'll enable receiveShadow on ground/rocks/logs/tent, foliage cast only. Actually foliage receiving firelight shadow makes trees flicker nicely... risk of acne on low-poly cones with PCF soft + bias -0.005... I'll set foliage receiveShadow = false, trunk receiveShadow = false. Ground receives everything → good flickering on ground. That's the safest look.

Directional moon shadow: trees cast onto ground — subtle bluish shadows. Need directional castShadow with camera bounds ±50, mapsize 2048, bias -0.0005, normalBias 0.5? For big ortho, normalBias helps. Set shadow.camera left/right/top/bottom ±55, near 1, far 200, position moonDir*80.

Hmm, one consideration: with both dir light shadows and point shadows and ~8 instanced draws + ~40 other meshes, shadow render cost is fine.

**Ambient occlusion fake:** add a dark radial "grounding" circle under fire (dark decal)? The dirt patch vertex color handles it. Under trees? skip.

**Now, code structure:**

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Night Camp — Low-Poly Forest Clearing</title>
<style> ... </style>
</head>
<body>
<div id="overlay"> title, hint </div>
<canvas id="scene"></canvas>? Let renderer create canvas and append.
<script type="importmap">...</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
...
</script>
</body>
</html>
```

CSS: body margin 0, overflow hidden, background #05070d; canvas display block. Overlay: position fixed, pointer-events none; title top-left with letter-spacing; hint bottom center; small vignette div. Also a subtle top gradient? Vignette via inset box-shadow on an overlay div: `background: radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.55) 100%)`. pointer-events none.

Hint: "Drag to orbit · Scroll to zoom" with icon-ish text. Fade hint after first interaction? Keep visible but subtle; on first pointerdown, fade out. Also autoRotate stops on interaction: controls.addEventListener('start', ...) — OrbitControls dispatches 'start' on interaction begin. Set autoRotate=false there.

Wait — the task doesn't ask for a game/demo-mode; it's a 3D scene, not a game, so the "self-playing demo / click to start" rule is about games. This is an interactive 3D scene; OrbitControls works with mouse without needing iframe focus (mouse events work without click focus? Pointer events on iframe work after user interacts... The sandbox note says keyboard needs click first; mouse drag should work directly). I'll still show hint text. No keyboard needed. Maybe add key shortcut? Not necessary; but could add Space to toggle autoRotate. Keyboard only after click — fine, it's a bonus. Skip keyboard to keep clean... Actually a small extra: pressing anywhere could toggle? Keep it simple: drag/scroll only.

Let me also add gentle idle motion: autoRotate true, speed 0.35, so the scene is alive immediately. Screenshot at 3s shows a nice angle regardless.

**Star twinkle shader:**

```js
const starGeo = new THREE.BufferGeometry();
positions, aSize, aPhase, aSpeed(random 0.5-2), color (mostly white-blue, few warm).
material = ShaderMaterial({
  uniforms: { uTime },
  transparent: true, depthWrite: false, blending: AdditiveBlending,
  vertex: gl_PointSize = aSize * (0.8 + 0.3*sin(uTime*aSpeed + aPhase)); pass alpha varying = twinkle
  fragment: circular falloff
});
```

Note: `fog: false` for ShaderMaterial by default (no fog chunks). Since stars at radius ~240 with linear fog far 95: without fog they render full brightness — good. But depth: they're inside sky sphere radius 300, drawn... render order: sky sphere is a mesh at 300; stars at 240 closer, fine with depth test. Sky sphere material depthWrite false? ShaderMaterial default depthWrite true; sphere at 300 → stars at 240 pass depth. OK. But camera far plane must exceed 300+ → far 800.

**Moon:** 
- Mesh: IcosahedronGeometry(5, 1) flatShading with MeshStandardMaterial? Unlit look better for emissive moon: MeshBasicMaterial color 0xf4ead0? But then flat facets invisible... MeshStandardMaterial lit by directional light from its own direction gives weird shading. Use MeshBasicMaterial — moon as flat pale disc with subtle texture? Low-poly stylized: I'd do MeshStandardMaterial with emissive 0xdfe6f0, emissiveIntensity 1, color 0x111111? Simpler: MeshBasicMaterial(0xe8eef8) — flat pale circle-ish. With icosahedron detail 1 silhouette is roundish. Add glow sprite radius ~28 behind. And a couple of "craters"? skip. MeshBasicMaterial with fog:false.

Moon light: directional light from moon direction.

Directional light position: moonDir*100, target 0. Note moonDir = (-0.42, 0.55, -0.72).normalize()*... compute: light.position.set(-42, 55, -72).

**Sky shader with moon halo:**

```glsl
varying vec3 vWorld; // use world direction
// vertex: vWorld = (modelMatrix * vec4(position,1.0)).xyz;
// frag: vec3 dir = normalize(vWorld);
float h = clamp(dir.y, 0.0, 1.0);
vec3 col = mix(uHorizon, uZenith, pow(h, 0.55));
// below horizon: darker
col = mix(uGround, col, smoothstep(-0.08, 0.02, dir.y));
float m = clamp(dot(dir, uMoonDir), 0.0, 1.0);
col += uMoonCol * (pow(m, 180.0)*0.9 + pow(m, 12.0)*0.18);
```

Sphere radius 400? camera far must be > 400... set far 1000. Fine (depth precision with near 0.1 far 1000: logarithmic not needed; near 0.1 → z-fighting risk on distant overlapping surfaces minimal since nothing overlaps far; sky vs stars 240 vs 400 with 24-bit depth fine-ish. Set camera near 0.5? Controls minDistance 4, near objects at ~1 from camera... near 0.2, far 900. ratio 4500 — ok for 24-bit depth (needs <~10^5 for no artifacts in mid-range; objects of interest within 60 units → precision fine).

Actually simpler: sky sphere radius 500, stars radius 450, moon at 420 distance with size ~26 → angular size same as real-ish. camera far 1200. Fine.

Hmm wait: fog. Linear fog far 95 — but fog applies per material; sky ShaderMaterial: no fog by default. Points/stars ShaderMaterial: no fog. Moon MeshBasicMaterial: set fog:false explicitly (Basic has fog:true default? Materials have fog:true default and scene.fog would apply → moon at 420 fully fogged to fog color if fog:true! Must set fog:false). Sprite glow material: SpriteMaterial fog default true → set fog:false. Good catch. Similarly fireflies ShaderMaterial no fog chunks — fine (they're within 20 units anyway; fog:false fine). Sparks ShaderMaterial — no fog, fine (close to fire). Smoke uses SpriteMaterial with fog... smoke near center, fog negligible. Set fog:false anyway? Sprites near fire — negligible. Leave default.

Tree/ground/rock/log/tent materials: standard materials with fog default true → fogged. 

**Fog choice:** Linear Fog(color 0x0d1626? , near 22, far 110). Trees at 16-48: at 48 units fog factor (48-22)/(110-22)=0.30 → 30% faded... "fog that fades the distant trees" — want more fade. Options: near 18, far 70: tree at 48 → (48-18)/52 = 58% faded; ground edge at 60+ fully fogged. Moonlit horizon color must match fog color at full fade. So fog color == sky horizon color == #0d1626-ish? Let me choose fog color 0x0e1524? Hmm, horizon should be a dark desaturated blue: #101a2e maybe slightly lighter for moonlit night. Let me set:

- fogColor = 0x0c1322
- sky horizon = same 0x0c1322, zenith = 0x020409? Actually gradient pow(h,0.55): at h=0.3 → 0.3^0.55 ≈ 0.51 → halfway. fine.

Let me be careful the scene doesn't end up "milky" — keep fog far enough that trees 16-30 remain clearly visible, 35-48 partially ghosted. near 20, far 80. At 48: (48-20)/60=0.47. OK.

Also `scene.background`? Using sky dome mesh so background irrelevant, but set scene.background = new Color(0x020409) as fallback.

**Tone mapping:** ACESFilmicToneMapping, exposure 1.15. Color space default sRGB output.

**Now flame geometry vertex attributes:** need aRand per vertex for phase variation — or use position-based pseudo-random in shader. Use per-vertex attribute aRand: fill Float32Array random per vertex. ConeGeometry(radius, height, 6, 4) — vertices duplicated? Cone is indexed with unique vertices mostly (radial seam duplicates). Fine.

Also I want flame silhouette wobble at top: displace more with height: amp = (0.10 + 0.18*h) * something... plus horizontal sway sin(uTime*3 + uPhase).

Let me write flame vertex shader:

```glsl
uniform float uTime;
uniform float uPhase;
uniform float uHeight;
attribute float aRand;
varying float vH;
void main() {
  vec3 p = position;
  float h = clamp(p.y / uHeight, 0.0, 1.0);
  vH = h;
  float t = uTime + uPhase;
  float sway = sin(t*2.2 + uPhase) * 0.12 * h;
  float w1 = sin(t*9.0 + p.y*5.0 + aRand*6.2831) * 0.09;
  float w2 = cos(t*7.3 + p.y*3.5 + aRand*6.2831) * 0.09;
  p.x += (sway + w1) * h * 1.2;   // strongest at top
  p.z += (cos(t*1.8+uPhase)*0.1*h + w2) * h * 1.2;
  // taper jitter at tip: pull tip sideways strongly
  p.x *= 1.0; // keep
  gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}
```

Hmm — also shrink radius near top naturally by cone shape. Additional vertical stretch flicker: p.y *= 1.0 + 0.06*sin(t*8.0+uPhase); nice.

Fragment as planned + subtle fresnel-ish edge fade using vH only. Additive.

Alpha: (1-smoothstep(0.55,1.0,vH)) * 0.85; also fade at very bottom slightly? no—base should be bright.

Also randomize per-flame color slightly via uPhase.

Flames setup: three cones:
1. big: r=0.55, h=1.7, pos y=0.12, scale base
2. medium: r=0.36, h=1.25, offset (0.15, 0.1, 0.1), phase +2
3. small core: r=0.2, h=0.8, offset (-0.05, 0.05, 0.05), brighter color (uIntensity 2.2)
4. maybe a wide low one: r=0.7,h=0.6.

Each has own ShaderMaterial (uniforms uTime shared via reference, uPhase unique, uHeight, uIntensity). Update uTime each frame for each.

Whole flame group also gets scale pulsing? The shader handles it.

Also add point "glow" sprite at fire center: additive radial gradient sprite, orange, scale pulsing 3.5-4.5, opacity 0.35 — creates volumetric glow halo. Yes! Big soft glow sprite at (0,1.0,0) — with canvas radial gradient (255,150,60). Scale ~6. It also flickers with light intensity. depthWrite false, depthTest true? If depthTest true, trees in front occlude glow — realistic. transparent additive. Good.

**Sparks count:** 140.

**Fireflies:** 30 points, positions animated in shader: each firefly has anchor (random in torus/clearing area radius 4-14, y 0.5-3) and sinusoidal offsets: p = anchor + vec3(sin(t*s1+p1), sin(t*s2+p2)*0.5, cos(t*s3+p3)) * 1.5. Alpha pulse: 0.15+0.85*max(0,sin(t*ps+pp))^3 (blink). Color: soft green-yellow 0xd8e86a / 0xbae67a. Size ~ 3-5 px attenuated. Additive. Fireflies usually blink — nice life.

Vertex shader with attributes: aAnchor(vec3), aSeed(vec4: s1,s2,s3,s4). Compute in shader — zero CPU work. 

**Smoke:** 8 sprites; CPU-animated: each has life 0..1 looping with different speeds/offsets; pos = (x0 + drift*life, 0.6 + life*4.5, z0), scale = 0.8 + life*3.2, opacity = sin(life*π)*0.09 (fade in/out). SpriteMaterial with gray texture, color 0x8a8378? At night smoke is dark against sky... with additive it would glow — use NormalBlending with dark gray color 0x2a2a30, opacity low → silhouette against sky/moon. Hmm, dark smoke normal-blended over bright moon = visible streak — moody. But over dark sky nearly invisible; near fire the additive glow behind... complexity. Simpler and safer: make smoke slightly warm-lit: color 0x554a40, NormalBlending, opacity 0.10 — will read as faint haze catching firelight near base. Include; subtle enough that even if barely visible, no harm.

Actually, let me use additive faint orange-white smoke? Smoke lit from below by fire is warm near base, cool/dark higher. Single-color compromise: 0x3a3630 normal blending low alpha. Go.

**Ground vertex colors detail:**

```js
const g = new THREE.PlaneGeometry(140, 140, 70, 70);
g.rotateX(-Math.PI/2);
const pos = g.attributes.position;
const colors = new Float32Array(pos.count*3);
const cMossA = new THREE.Color(0x24331f), cMossB = new THREE.Color(0x2e3d26)... 
```

Wait, colors: base moss dark green range; dirt near fire: 0x3a2c1e; sprinkle darker patches via noise. Compute per-vertex:

```js
for i: x,z from position; y = groundHeight(x,z); set y.
d = hypot(x,z)
n1 = noise-ish value in [0,1] from sin combos
col = lerp(cA, cB, n1)
dirt = smoothstep(3.2, 1.6, d) → 1 near center
col = lerp(col, cDirt, dirt*0.85)
// slight darkening far away for depth? fog handles.
```

Then computeVertexNormals (flatShading overrides normals in shader anyway — with flatShading the shader uses derivative normals, so vertex normals unused; fine either way).

Rock/log/tree y placement uses groundHeight.

But careful: groundHeight with flat dampening: near center d<2 → height 0. Rocks/logs near fire sit at y≈0 — good. Rocks ring exactly at d≈1.15 → height ~0. Campfire group at y=0.

Beyond center, groundHeight smooth-ish sin fields: fine for placement.

Edge case: ground plane 140 wide but displacement function evaluated beyond ±70 fine.

**Check "no horizontal scrollbar 375px":** canvas fills window, body overflow hidden. Good.

**Performance:** ~50 instanced tree meshes (8 instanced draws), ~30 rocks/logs meshes, ground 70×70 segs (~9800 tris ok), shadow passes. Fine.

**Potential pitfall — OrbitControls maxPolarAngle:** to keep camera above ground: maxPolarAngle = 1.45 (~83°). Since target y=1.2 and camera minDistance 5, at polar 83° camera y = 1.2 + 5*cos(83°)=1.2+0.61=1.81 > ground (~0 near center; at distance 5*sin83≈4.97 from center groundHeight small ±0.5). Camera stays above. Also minPolarAngle 0.1. Also `controls.enablePan = false` to prevent target drifting below ground? Panning could move target weirdly — disable pan for cleanliness. Also clamp target fixed.

Also collision with trees visually — fine.

Also add `controls.maxDistance = 26`, `minDistance = 4.5`.

**Star field generation:**
```js
const N=900;
dir random on sphere with y>0.03 (bias toward more at mid altitudes?), r=460;
sizes: mostly 0.8-1.8, 6% bright 2.5-3.5;
colors: white 0xdde8ff, some 0xfff2d8, some blue 0xbfd0ff;
aPhase random 0..2π, aTw speed 0.3-1.6.
```
gl_PointSize: aSize * twinkle? size fixed, alpha twinkle: alpha = 0.55 + 0.45*sin(uTime*aTw + aPhase) — some stars twinkle more: multiply by aAmp. Let alpha = 0.35 + 0.65*(0.5+0.5*sin(...)) * aAmp + (1-aAmp)... simplify: `float tw = 0.5 + 0.5 * sin(uTime * aTw + aPhase); float alpha = mix(0.9, 0.35 + 0.6 * tw, aAmp);` with aAmp 0..1 random. Good.

Point size in pixels: `gl_PointSize = aSize * uPixelRatio;` since sizeAttenuation false behavior. Multiply by pixel ratio for consistent device-independent... fine, pass uPixelRatio uniform, update on resize.

**Moon glow sprite texture:** canvas 256: radial gradient center rgba(255,244,220,1) → transparent, plus slight falloff^2. Sprite scale ~55. Moon disc: radius 13 icosahedron at distance 420 → angular ≈ 2*atan(13/420) ≈ 3.5° — moon real is 0.5° but stylized bigger is nicer. Make 16.

Also secondary faint halo in sky shader (pow 12 * 0.18) — combined nice.

**Fire light shadow:** PointLight castShadow with mapSize 1024 (cube). shadow.bias = -0.006? For point lights bias negative small; also normalBias 0.02. I'll set bias -0.005, normalBias 0.05? Hmm normalBias for cube shadows works. Use bias: -0.004, normalBias: 0.02... To be safe against acne on ground with flatShading derivative normals — flat shading + normalBias can cause peter-panning slightly; acceptable. I'll go bias -0.008? Over-bias causes light leaks around rocks (light bands). Middle ground: bias -0.005.

Hmm, one more consideration: PointLight shadow near: 0.1? default near 0.5 — logs within 0.5 of light would not cast... light at y≈1.15, flames above. near 0.05 better: shadow.camera.near = 0.05, far = 50.

**Directional moonlight shadow:** ortho ±55, near 5, far 250, mapSize 2048, bias -0.0006, normalBias 0.6? For ortho with world scale 110, texel size ~110/2048=0.054; normalBias ~0.05*... typical normalBias in world units ~ 0.5? That seems big. Use normalBias 0.15, bias -0.0005. Eh — I'll set `shadow.normalBias = 0.4`? Too much maybe. Trees' shadows slightly offset is unnoticeable at night. Go with normalBias 0.25, bias -0.0005.

Hmm — do I even want moon shadows? Trees casting long moon shadows looks great in screenshots. Yes keep, values reasonable; even if slightly imperfect, dim light hides artifacts (intensity 0.35 of a dark blue — artifacts subtle).

**Hemisphere + ambient:** HemisphereLight(0x4a5f8a, 0x11150e, 0.55)? plus faint AmbientLight(0x223344, 0.35)? Careful not to wash out night. Let me: hemisphere (0x3b4d78, 0x151310, 0.75). Directional moon 0xa8c0e8 intensity 0.5? At night with ACES, these combine... I'll estimate final: these give moonlit visibility so trees aren't pure black. Fire light dominates near center. Looks good.

Let me now also think: MeshStandardMaterial roughness 0.95 ground, rocks 0.9, trunks 0.9, foliage 0.85 metalness 0. Foliage maybe slight emissive? no.

**Tent placement:** at (-5.5, h, 3.8)? Camera initial at (7.5,4.2,12) looking at center — tent at left-back visible. Fire glow hits it. rotation: face fire: angle = atan2(fire.x - tent.x, fire.z - tent.z) → tent front (+z local) should point toward fire: rotation.y = Math.atan2(0 - x, 0 - z). For tent at (-5.5, 3.8): atan2(5.5, -3.8) ≈ 2.18 rad. Extrude front at +z local. Good.

Also add a "sleeping" detail: no.

Add backpack? skip.

Firewood pile: 5 cylinders stacked pyramid (3+2) near fire at (2.4, ., 1.8) area, radius 0.1 length 1.1. Yes.

**Code for rocks (scattered + ring):**

```js
function makeRock(size, seed){ geometry = new THREE.IcosahedronGeometry(size, 0); jitter vertices with pseudo-random based on index & seed; }
```
Jitter: iterate position attribute, displace each vertex by (hash-0.5)*size*0.45 — since icosahedron detail 0 is non-indexed? IcosahedronGeometry detail 0: it's indexed? IcosahedronGeometry extends PolyhedronGeometry which produces non-indexed with duplicated vertices per face — jittering duplicates independently would tear faces apart (cracks). To avoid cracks: displacement must be position-hash based (same for identical positions): use hash of rounded position. 

```js
const v = new THREE.Vector3();
const key = (x,y,z) => `${x.toFixed(3)},${y.toFixed(3)},${z.toFixed(3)}`;
map of position → offset computed once.
```
Do that: for each vertex, key by position string; if unseen, generate offset vector; apply. This keeps shared positions welded. 

Then scale mesh non-uniform: scale.set(s*(0.7+r), s*(0.5+r), s*(0.8+r)).

Rocks receive+cast shadow.

**Logs around fire (benches):** two logs radius 0.22, length 2.0, at distance 2.6 from fire, on opposite sides, aligned tangentially (like seating). Plus 3 fallen logs scattered at 5-10 radius. Cylinder radialSegments 6, capped. Bark color 0x4a3423 with roughness 1. Maybe slight color per log.

**Also small sticks/debris near fire?** skip.

**Animation loop:**

```js
const clock = new THREE.Clock();
function tick(){
  requestAnimationFrame(tick);
  const t = clock.getElapsedTime();
  const dt = Math.min(clock.getDelta()... 
```
careful: getElapsedTime() then getDelta() — getElapsedTime updates internal; use `const dt = clock.getDelta(); const t = clock.elapsedTime;` — getDelta adds to elapsedTime. Yes: call getDelta first, then read clock.elapsedTime.

Update: flame uniforms uTime = t; light flicker; ember emissiveIntensity = 1.2 + flick*1.5; glow sprite scale/opacity; sparks uniform uTime; fireflies uTime; stars uTime; smoke CPU update; controls.update(); renderer.render.

Flicker value: 
```js
const fl = Math.sin(t*10.7)*0.55 + Math.sin(t*23.3+1.7)*0.25 + Math.sin(t*4.1+0.8)*0.2 + Math.sin(t*6.7+3.0)*0.15; // ~[-1.15,1.15]
light.intensity = 210 * (1 + fl*0.14);
```
plus slow drift random — sines at incommensurate freqs look random enough.

Also light position jitter: `light.position.set(Math.sin(t*13.1)*0.07, 1.05 + Math.sin(t*9.4)*0.05, Math.cos(t*11.3)*0.07)`.

Embers pulse: `emberMat.emissiveIntensity = 1.6 + fl*0.8` clamp>0.

Glow sprite: `glow.material.opacity = 0.32 + fl*0.05; glow.scale.setScalar(5.6 + fl*0.7);`

Also secondary fill light? A dim orange PointLight without shadows at y=2.5 to soften? The main plus hemisphere fine.

**Also — heat distortion?** skip.

Let me also double-check the flame additive + tone mapping: ShaderMaterial gl_FragColor not tone-mapped? ShaderMaterial by default: `material.toneMapped = true` (default true) — for ShaderMaterial, tonemapping is applied only if... Actually tone mapping is applied in the fragment shader via #include <tonemapping_fragment> which is added when material.toneMapped && renderer.toneMapping set — for ShaderMaterial, the tonemapping chunk is NOT automatically included; raw ShaderMaterial outputs are not tone mapped (the chunk must be manually included). Built-in materials include it. So flames additive colors with values ~1.5-2 will clip bright — that's fine (they should look blazing). Colors will render vivid since no ACES on them — acceptable, even desirable for fire. Same for sparks/stars — they'll be crisp. OK.

Wait, actually is that right? In modern three, `WebPrograms` injects tone mapping only for materials where `material.toneMapped` is true AND the shader includes the chunk. ShaderMaterial user shaders don't include the chunk unless written. So yes raw output. Also output color space conversion: `colorspace_fragment` chunk also not included in raw ShaderMaterial → linear values written to sRGB framebuffer → will appear darker/more saturated... hmm. Values > 1 clamp; linear 0.5 written as 0.5 sRGB-encoded appears as mid-gray (brighter than linear 0.5 should be). Effect: flame colors slightly brighter — fine for fire. Sky dome ShaderMaterial: colors I set are chosen as if display values anyway — I'll pick uniform colors by eye assuming they're roughly display-referred. It's a stylistic scene; pick pleasing values.

Alternatively add `#include <tonemapping_fragment>` + `#include <colorspace_fragment>` in sky shader... These chunk names exist in r170: 'tonemapping_fragment' and 'colorspace_fragment'. For raw ShaderMaterial, includes resolve since ShaderChunk is available for #include in any material compiled by WebGLProgram? Yes, #include statements are processed for ShaderMaterial too (resolveIncludes runs on all). So I can add both includes to sky and flame shaders for consistency. Let me do that for the sky (so its colors interact with exposure like everything else) and flames (avoid weird clipping differences)... but for flames additive, tone mapping each layer separately is wrong-ish (they add after). Keep flames raw/bright. For sky: include colorspace + tonemapping so horizon matches fogged standard materials (which are tone mapped + sRGB). Fog on standard materials: fog color blended in... fog color is in linear or sRGB? Fog color uniform is converted... In three, colors set via new THREE.Color(hex) are sRGB→linear converted (r152+ color management). Fog blends in linear space pre-tonemapping, then tonemapped. Sky dome raw would mismatch horizon vs fog color unless I apply same transforms. To guarantee horizon seam matches distant fully-fogged ground: apply `#include <tonemapping_fragment>` and `#include <colorspace_fragment>` in sky shader and pass fog color as linear (Color with hex does auto conversion when passed as uniform? Uniform colors: if I set uniform value = new THREE.Color(0x0c1322), the Color stores linear-converted components (ColorManagement enabled default converts hex to linear-srgb working space). Then sky shader mixing linear colors, then tonemap+encode → matches standard fog pipeline. 

So sky fragment ends:
```glsl
gl_FragColor = vec4(col, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
```
These operate on gl_FragColor. In r170 chunk names: 'tonemapping_fragment' and 'colorspace_fragment' — correct (renamed from encodings_fragment in r152).

For star/spark/firefly shaders: leave raw (they're emissive points; brightness tuned by constants). But color space: raw linear written → appears darker. Compensate by choosing brighter values or add colorspace include only: `#include <colorspace_fragment>` — makes them consistent. Additive + colorspace conversion is fine. I'll include colorspace chunk in those point shaders so what I author (0.9, 0.5, 0.2 etc as sRGB intents) appears as intended... wait colorspace_fragment converts linear→sRGB. If I author color as if linear then output converts to sRGB brightening mid values. If I want display color (1.0,0.5,0.2), author linear = sRGB(1.0,0.5,0.2)^2.2... Simplest: include the chunk and author colors via THREE.Color(hex) uniforms/attributes (which are linear) → output matches hex display. For per-vertex color attributes I write Color().r/g/b (linear) → correct with chunk. 

Decision: all custom shaders end with tonemapping? Stars: tone mapping would dim them (ACES dims small values). Skip tonemapping for point sprites; include colorspace so hues read right. For flames: include colorspace too; author via colors chosen as linear values by feel (I'll pick values that look good; e.g. linear (1.0, 0.35, 0.05) → display sRGB ≈ (1, 0.64, 0.26) hmm brighter orange). I'll just tune constants assuming linear→sRGB brightening; typical flame shader values like (1.5, 0.5, 0.1) * intensity with additive stacking will look hot regardless. Fine.

Simpler robust alternative for sky: skip ShaderMaterial sky, use `scene.background = new THREE.Color(...)` + fog — but gradient + moon halo is much prettier. Keep shader sky with chunks.

Let me now also handle **resize**: update camera.aspect, renderer.setSize, uPixelRatio uniforms for point shaders.

**HTML/CSS polish:**

```css
:root { color-scheme: dark; }
html, body { height: 100%; }
body { margin: 0; overflow: hidden; background: #04060c; font-family: "Segoe UI", system-ui...; }
#app canvas { display:block; }
.ui { position: fixed; inset: 0; pointer-events: none; }
.title { position: absolute; top: 28px; left: 32px; ... }
.title h1 { font: 500 clamp(18px, 2.6vw, 30px)/1 'Georgia', serif; letter-spacing: .42em; text-transform: uppercase; color: #e8dcc2; }
.subtitle small line
.hint { position:absolute; bottom: 26px; left:50%; transform: translateX(-50%); ... pill with border }
.vignette { position:absolute; inset:0; background: radial-gradient(120% 90% at 50% 40%, transparent 55%, rgba(2,4,10,.55)); }
transition hint fade on interaction: add class .idle
```

Title color warm parchment; maybe small flame emoji? No emoji — use a small CSS diamond or SVG inline icon? Keep text only, classy. Maybe a tiny inline SVG flame icon next to title — inline SVG allowed (self-contained). Sure, small flame svg 16px, warm color.

Hint pill: "Drag to orbit · Scroll to zoom · Auto-orbit until you move" — text like "drag to look around — scroll to zoom". Add key: none.

Fade the hint after first pointerdown (add class fading → opacity 0 transition 1s). Keep title persistent.

Also a small "loading" fallback: before module loads, body shows title + hint on dark bg — fine; add tiny "lighting the fire…" text that gets removed on init? Module CDN load usually <1s. Add a small loading dot; remove on first frame. Simple: `<div id="loading">igniting…</div>` remove after init. Screenshot at 3s — by then loaded. Include it, hide via JS.

**Now write all code carefully.**

Let me write the groundHeight function and ensure consistent usage:

```js
function groundHeight(x, z){
  const d = Math.hypot(x, z);
  const flat = THREE.MathUtils.smoothstep(d, 2.5, 12);
  let h = Math.sin(x * 0.16) * Math.cos(z * 0.19) * 0.9
        + Math.sin(x * 0.42 + 1.7) * Math.cos(z * 0.35 + 0.6) * 0.4
        + Math.sin(x * 0.85 + 0.3) * Math.sin(z * 0.78 + 1.1) * 0.18;
  return h * flat;
}
```
smoothstep(x, min, max) in MathUtils — signature (x, min, max). Yes.

Tree y: groundHeight(x,z) - 0.05 (sink slightly). Trunk base at ground: trunk geometry cylinder centered → translate geometry up by half height so base at 0: trunkGeo.translate(0, 0.8, 0) for h=1.6. Foliage cones: cone geometry base at ... ConeGeometry centered; I'll translate cone geo so base at 0 then position levels at y offsets.

Pine design (unit scale, scaled per tree by s):
- trunk: r 0.13→0.2, h 1.6, base y0.
- foliage levels: 
  - L1: cone r 1.25, h 1.7, base y 0.55
  - L2: cone r 0.95, h 1.5, base y 1.45
  - L3: cone r 0.62, h 1.3, base y 2.3
  → tree total ≈ 3.6 units * scale 0.8-1.6 → 2.9-5.8 tall. Good.

Deciduous:
- trunk: h 2.0, r 0.15→0.22, base 0
- blob1: icosahedron r 1.15 detail 0 at y 2.6, scale (1.15, 0.95, 1.15) relative → set via matrix scale
- blob2: r 0.8 at (0.7, 2.0, 0.4) 
Tree scale overall.

Instancing: I'll create arrays of matrices per part. Use dummy Object3D.

Per-tree transform: position (x, yBase, z), rotation y, scale s (uniform-ish; maybe xz jitter). For each part compute matrix = treeMatrix * partLocalMatrix. Compose with:

```js
const m = new THREE.Matrix4(), q = new THREE.Quaternion(), e = new THREE.Euler();
function setPart(inst, idx, tree, part){ ... }
```
Easier: dummy hierarchy — create dummy, set tree transform, then for each part use second dummy with local offset, then `partDummy.applyMatrix?` Manual compose:

```js
const tm = new THREE.Matrix4().compose(pos, quatY, scaleS);
const pm = new THREE.Matrix4().compose(partPos, partQuat, partScale);
instMesh.setMatrixAt(i, tm.clone().multiply(pm));
```
Fine (allocation per tree at init only).

Colors per instance: `inst.setColorAt(i, color)` — instanceColor multiplies material color... In shader: vColor = instanceColor * color? For MeshStandardMaterial with instanceColor, final diffuse = material.color * instanceColor. So set material color white and instance color actual. 

Wait: does setColorAt require material.vertexColors? No, instancing color is separate (USE_INSTANCING_COLOR), works automatically.

Foliage color variation: base pine color random pick among 2-3 greens: 0x1b3a26, 0x1e4426, 0x173822, slightly toward blue 0x1c3b2e... plus per-instance hue jitter via offsetHSL. Also some autumn-ish? Night scene — keep greens, maybe one or two with dark teal.

Deciduous: 0x27402a, 0x2e4522, 0x24391f.

Trunks: browns 0x4a3527, 0x3f2d20, 0x523a28 with jitter.

**Shadow settings for InstancedMesh:** castShadow true; also `inst.instanceMatrix.needsUpdate` not needed (set before first render, but set anyway).

Count: nPines = 34, nDecid = 16 → 50 trees ≥ 30 ✓.

Placement: 
```js
const treePts = [];
function placeTree(){ 
  let x,z,ok,tries=0;
  do { const a = Math.random()*Math.PI*2; const r = 16 + Math.sqrt(Math.random())*32; x=Math.cos(a)*r; z=Math.sin(a)*r; ok = treePts.every(p=>((p.x-x)**2+(p.z-z)**2) > 2.4**2); } while(!ok && ++tries<20);
  ...
}
```
r up to 48. sqrt for uniform density. min dist 2.4.

Also keep trees off the moon-view line? fine.

**Scattered rocks:** 7 rocks radius 0.25-0.75 at r 3..11, plus ring of 9 small rocks radius ~0.18-0.28 at r 1.05-1.25 around fire. Plus 2-3 big boulders far (r 10-14, size 1.2-1.8) for interest.

**Logs:** 
- bench logs: 2 at (±?) Let me place: log benches at angle 0.6π and 1.55π, radius 2.7, length 2.2, oriented tangentially.
- fallen logs: 3 at random radius 5-10, random yaw.

**Firewood pile:** at (2.6, y, 1.9): 3 logs parallel on ground + 2 on top, radius 0.09, len 1.2. Rotate group random.

**Tent:** as planned at (-5.6, ~, 3.6).

Check tent/fire spacing: tent 6.65 from fire — receives warm light at 6.65 → intensity 210/44 ≈ 4.8 lux-ish bright-ish. fine.

**Fire structure:**
- ring stones: 9 rocks.
- teepee logs: 5 logs, bottom radius 0.6, lean angle ~0.45 rad from vertical: create cylinder h 1.5 r 0.09; position bottom at angle θ: bx=cos θ*0.6, bz=sin θ*0.6; tilt toward center: rotation axis perpendicular... Easiest: for each log, use lookAt-style: create mesh, set position at (bx, 0.7, bz) (mid height ~0.75), then rotate: rotate the log (its axis along Y) to lean toward center: rotation.z? Compose: rotate around axis (sinθ?, 0, -cosθ?)... Simple method: 

```js
const log = new THREE.Mesh(logGeo, logMat);
const tilt = 0.42; // radians from vertical
log.position.set(Math.cos(a)*0.55, 0.62, Math.sin(a)*0.55);
log.rotation.set(0, -a, 0); // orient
// then tilt about local x toward center:
log.rotateX(tilt); // hmm sign
```
After rotation.y = -a, the log's local -z? Let me think: I want log top to lean toward origin. Position at angle a from center. If I first set rotation.y = -a... Alternative clean approach: parent trick — set log.position to (0.55·cos a, 0.6, 0.55·sin a) and use quaternion setFromUnitVectors? 

Cleanest: build via pivot: create group at bottom point (bx, 0.05, bz); log added as child at (0, 0.75, 0) (pointing up, base at group origin if geo translated); then rotate group so its +Y axis tilts toward center: group.rotation order: rotate around axis perpendicular to radial direction: axis = (−sin a, 0, cos a)? The radial outward dir is (cos a, 0, sin a). Tilting top toward center = rotating around tangent axis t = (−sin a, 0, cos a) by angle tilt such that y-axis moves toward −radial. Use quaternion: q.setFromAxisAngle(axis, tilt) where axis = tangent with correct sign.

Simplest hack that definitely works: 
```js
log.position.set(bx, 0.62, bz);
log.lookAt(0, 1.35, 0); // aims +z? lookAt orients -z toward target for cameras but for objects +z? Object3D.lookAt makes -z? For non-camera objects, lookAt orients the object's +z axis toward target? Actually Object3D.lookAt rotates so that its local +z faces the target (cameras look down -z, so cameras face away — for cameras lookAt points -z at target). For normal objects +z toward target. Hmm: three.js docs: "Rotates the object to face a point in world space." Implementation: for cameras and lights it uses lookAt matrix with negative z; for other objects positive z faces target.
```
So: log with cylinder along Y... mismatch of axes. Better: make logGeo a cylinder rotated to lie along Z: geo.rotateX(PI/2) → axis along z, centered. Then translate geo so its base at z=-h/2 → geo.translate(0,0,h/2)? then bottom (fire end) at z=0. Then place log at bottom point p=(bx,0.12,bz), lookAt(0, 1.5, 0): +z aims up-inward; log extends from p toward the target direction... length 1.55, target at height 1.5 — angle ~ atan(0.55 horizontal / (1.5-0.12) vertical) ≈ 21.8° from vertical — good teepee. The log's far end lands near center top. 

lookAt for Mesh: orients +z toward target — yes for non-camera Object3D, lookAt makes the object's positive z-axis point at target. Confirmed (three.js Object3D.lookAt: "rotates to face position... assumes object's forward is +z" — actually it constructs m4.lookAt(eye, target, up) then quaternion from it; Matrix4.lookAt makes -z point from eye to target... For cameras -z toward target. Object3D.lookAt: `if (this.isCamera || this.isLight) m1.lookAt(position, target, up) else m1.lookAt(target, position, up)`. So for regular objects, +z points toward target. ✓.

So teepee: 5 logs with geo: CylinderGeometry(0.085, 0.11, 1.6, 6), rotateX(PI/2) → axis z, then translate(0, 0, 0.8) so base at origin extending +z. Position at (cos a*0.58, 0.1, sin a*0.58), lookAt(0.0, 1.15, 0.0)? If target exactly (0,1.15,0), all logs converge to center point — tops overlapping in x-shape — realistic teepee-ish. Charred color 0x241610, castShadow.

- ember cluster: 6 small dodecahedron/tetra at center r 0.3, y 0.08, emissive 0xff4400 int 2, color 0x1a0c06.

**Flames:** at y 0.1.

**Light:** main PointLight at (0, 1.3, 0).

**Glow sprite** at (0, 0.9, 0).

Now the sparks shader — check gl_PointSize formula: size * (uPixelRatio) * (100 / -mv.z)? Standard: `gl_PointSize = size * ( scale / -mvPosition.z )` where scale ≈ canvasHeight/2 / tan(fov/2)... I'll use uPointScale uniform = renderer height * 0.5 / tan(fov/2 rad)... let me just compute: `uScale = height / (2 * Math.tan(THREE.MathUtils.degToRad(camera.fov * 0.5)))` and gl_PointSize = aSize * uScale / -mv.z. With aSize ~ 0.05 world units → at distance 14: 0.05 * (400/tan27.5°≈768)/14 ≈ 2.7px. Update uScale on resize. Good for both sparks and fireflies (different base sizes).

Sparks data: count 130; attributes aSeed(rand), aSpeed(0.25-0.6 loop/sec), aAngle, aSpread... life = fract(uTime*aSpeed + aSeed). Height climbed = life * (2.5 + aSeed*2.5). Horizontal drift: r = 0.12 + life*aSeed*0.9; wobble sin. Fade: alpha = (1-life)^1.5 maybe * smoothstep(0,0.08,life). Size 0.02-0.05 shrinking with life.

Fireflies: anchors random ring r 3..13 (avoid directly over fire? fine anywhere), y 0.6..2.8; motion amp 1.2; blink. Color: 0xcfe36a-ish; alpha 0.05..0.9 pulse. Size 0.06 → few px.

Both additive, depthWrite false, transparent, depthTest true.

Render order: transparent additive things render after opaque; sprites/smoke sorted — ok.

One catch: additive sparks in front of flame cones: flame cones also additive transparent depthWrite false → order by distance, blending additive commutative → fine.

**Star shader** with uPixelRatio for size: gl_PointSize = aSize * uPixelRatio (fixed screen size). Sizes 1..3, few 4.

**Sky:** SphereGeometry(520, 32, 20)... camera far: 1500. Hmm near 0.2/far 1500 → depth ratio 7500. 24-bit depth ok for our sparse scene; sky sphere at 520 with depthWrite... Let me set sky material depthWrite: false and renderOrder -100? If depthWrite false and rendered first (renderOrder -1), everything else draws over it. Stars at 480 drawn later → visible. Moon at 470 → visible. Set sky.renderOrder = -10; skyMat.depthWrite = false. And camera.far = 1200, stars r=480, moon 470, sky 520. Good; depth precision concerns minimal since main content within 60.

Wait, fog far 80 means distant ground fades to fogColor; beyond ~100 units ground geometry fully fog colored, matching sky horizon color → seamless. Ground extends to radius ~99 (140×140 plane half-diagonal). Sky horizon color == fog color exactly (same Color passed). 

**Fog + tone mapping check:** standard materials: fog applied in linear pre-tonemap; sky horizon raw color with tonemapping chunk → same transform as ground fogged pixels. Both start from same fog color value (linear) → match. ✓ (assuming sky shader gets color as linear: uniform new THREE.Color(0x0c1322) → stored linear (since ColorManagement converts on set from hex). Fog color: scene.fog = new THREE.Fog(new THREE.Color(0x0c1322)) → same linear. ✓)

**Moon direction shared:** const moonDir = new THREE.Vector3(-0.45, 0.62, -0.64).normalize(). Sky uniform uMoonDir = moonDir. Moon mesh position = moonDir * 470. Dir light position = moonDir * 90 (needs to be within shadow camera range: light positioned 90 away, target origin; shadow ortho ±60 covers scene; fine — directional light shadow camera is positioned at light looking at target.)

Hmm, shadow: DirectionalLight position at (-43, 60, -61): shadow.camera.near default 0.5, far default 500? Directional shadow camera near/far relative to light position: set near 10, far 220 to cover from light to beyond ground. left/right/top/bottom ±60.

Moon in initial camera view: camera (7.5, 4.2, 12) target (0,1.2,0). View dir ≈ normalize(-7.5, -3, -12) = (-0.52, -0.21, -0.83). Moon dir (-0.45,0.62,-0.64): angle between view dir and moon dir: dot = 0.234 + (-0.13)?? compute: (-0.52)(-0.45)=0.234; (-0.21)(0.62)=-0.13; (-0.83)(-0.64)=0.53 → total 0.634 → angle ~50.7°. FOV 55 vertical → horizontal at 16:9 ≈ 55*1.78·vertical-half... horizontal half-FOV ≈ atan(tan(27.5°)*1.6) ≈ 40°. Vertical half 27.5°. Moon offset from center: 50° total — depends on direction: moon is up-left-back; view center is down-forward. The angular offset: mostly vertical? Let me compute components: view forward f = (-0.52,-0.21,-0.83). Camera right vector r = normalize(cross(f, up))... approx right = (0.916?, ...). Let me quickly: up world (0,1,0). right = f × up? Camera right = normalize(cross(forward, up))? cross(f, up) = f × (0,1,0) = (f.y*0 - f.z*1... let me compute cross((fx,fy,fz),(0,1,0)) = (fy*0 - fz*1, fz*0 - fx*0, fx*1 - fy*0) = (-fz, 0, fx) = (0.83, 0, -0.52) normalize → (0.847, 0, -0.53). Moon-right component: m·r = (-0.45)(0.847) + (-0.64)(-0.53) = -0.381 + 0.339 = -0.042 → nearly centered horizontally! Vertical: camera up' = cross(r, f) ≈ ... vertical offset component ≈ m·up' where up' = r × f = (0.847,0,-0.53) × (-0.52,-0.21,-0.83) = ( (0)(-0.83) - (-0.53)(-0.21), (-0.53)(-0.52) - (0.847)(-0.83), (0.847)(-0.21) - 0 ) = (0 - 0.111, 0.2756 + 0.703, -0.178) = (-0.111, 0.979, -0.178). m·up' = (-0.45)(-0.111) + (0.62)(0.979) + (-0.64)(-0.178) = 0.05 + 0.607 + 0.114 = 0.771. So moon is 0.771 up in view space → angle atan(0.771 / sqrt(1 - 0.634² - ...)) roughly: view-space coords: right -0.042, up 0.771, forward component = dot(m, f) = 0.634 (moon is in front, 50° off axis). Projected: horizontal ≈ -0.042/0.634 = -0.066 → tan(hAngle) = -0.066 → -3.8°; vertical ≈ 0.771/0.634 = 1.217 → tan(vAngle)=1.217 → 50.6° above center — way above vertical half-FOV 27.5° → moon NOT visible initially! 

I need the moon visible in the initial framing (screenshot!). Options: raise camera target/composition so moon appears: lower the look direction (target lower → camera pitches down → moon moves up? No—pitching down moves moon further off-screen upward). To include moon, camera should pitch up: target below camera? The camera at y 4.2 looking at y 1.2 pitches down already; moon ends up high. To get moon in frame: put moon lower on horizon-ish (elevation ~30°) and/or aim camera slightly upward (target above camera height? weird) or widen FOV. 

Better: position moon lower: moonDir elevation ~0.35: moonDir = normalize(-0.55, 0.38, -0.74)? Then vertical component: m·up' with new m... roughly moon elevation 22°: view-space up component ≈ sin(22° + view pitch offset)... The camera looks down by ~12° (atan(3/14.5)). Moon at elevation e appears at e + 12° above view center (if aligned in azimuth). Vertical half-FOV 27.5 → need e + 12 ≤ ~24 (leave margin) → e ≤ 12-15°?? That puts moon near horizon: elevation ~12°. Hmm that's a low moon, but fine artistically (big low moon over treeline = gorgeous, classic). Alternatively aim camera up a bit: keep camera y lower, e.g., camera (8, 2.6, 13), target (0, 1.6, 0) → pitch down only atan(1/15.3) ≈ 3.7°. Moon at elevation 22° → appears ~25.7° up → just within 27.5 half-FOV, near top edge. Add margin: elevation 18° → appears ~21.7° → comfortably visible near top. 

But wait — low moon elevation affects moonlight direction: light from elevation 18° → long dramatic shadows — actually beautiful. And sky halo near horizon where sky is brighter — nice.

Also with moon azimuth: make it appear left-of-center: azimuth of view is toward (-0.52,-0.83) horizontal → angle atan2(-0.52, -0.83) ≈ atan2 → 212°? Let me just compute desired moon azimuth: place moon toward the direction camera faces but offset: camera faces azimuth θv = atan2(fx, fz)?? Facing direction horizontal (-0.52, -0.83): azimuth = atan2(-0.52, -0.83) = π + atan2(0.52, 0.83) = 180° + 32° = 212° (i.e., pointing southwest-ish in xz). Put moon azimuth ≈ 190°±20 to be within horizontal FOV: 212 - 20 = 192 → moon at azimuth 192°, i.e., direction (sin? ...) Let me define moon azimuth angle φ from +x: dir = (cos φ_x?? I'll param: moonDir = normalize( (Math.cos(az)*cosEl, sinEl, Math.sin(az)*cosEl) )? With az measured so that (cos az, sin az) in xz. Facing azimuth: f_h = (-0.52, -0.83) → az_f = atan2(-0.83, -0.52)... I'm overcomplicating. Let me param: az such that dir.x = Math.sin(az)*cosEl? Ugh. 

Direct approach: pick moonDir = normalize(-0.35, 0.30, -0.89). Horizontal component (-0.35, -0.89): compare to facing (-0.52, -0.83): nearly aligned (angle between ≈ atan diff ~ 8°). Elevation: 0.30/1 → sin el = 0.30 (normalized) → el ≈ 17.5°. Camera pitch down ~3.7° → moon appears ≈ 21° above center. Within 27.5. ✓. Horizontal: offset ~8° < 40. ✓. So moon visible upper-center slightly left. 

Set moonDir = new THREE.Vector3(-0.35, 0.30, -0.89).normalize() ≈ /|1.0| (0.35²+0.30²+0.89² = 0.1225+0.09+0.7921=1.0046 ≈ 1) ≈ (-0.349, 0.299, -0.888). Elevation 17.4°.

Camera: position (8, 2.8, 13.5)? distance = sqrt(64+ (1.6)² + 182) ≈ 15.7. Pitch: (2.8-1.6)/15.5 → 4.4° down. Moon apparent elevation ≈ 17.4+4.4 = 21.8° ✓ within frame (top edge 27.5°). At 375px width (aspect 0.47), horizontal half-FOV = atan(tan27.5 * 0.47) = atan(0.245) = 13.7° — moon at ~8-9° horizontally... need exact: horizontal offset of moon from view center: compute later — risky for mobile, but mobile still sees fire fine; moon maybe off-frame on narrow — acceptable? "adapt gracefully" — main subject fire stays centered. To be safer, reduce moon horizontal offset: moonDir horizontal (-0.35, -0.89) vs facing (-0.52,-0.83): angle between horizontal dirs: cos = (0.35*0.52 + 0.89*0.83)/(0.613*1.16) = (0.182+0.739)/0.711 = 0.921/0.711... wait normalize: |(-0.35,-0.89)| = 0.956; |(-0.52,-0.83)| = 0.98. cos = (0.182+0.739)/(0.956*0.98)=0.921/0.937 = 0.983 → angle 10.6°. At 375px, horizontal half-FOV 13.7° → moon at 10.6° still in frame (barely). OK good enough. Or bump camera z to change? Fine.

Also check moon elevation angle from Moon's mesh: it's fine.

Moonlight direction from low elevation → shadows stretch across ground toward camera-ish — dramatic. 

Also treeline silhouette against moon halo — pretty.

**Star visibility:** stars everywhere; fine.

**Check initial framing content:** fire center slightly below middle? Camera at (8, 2.8, 13.5), target (0,1.2,0) — fire near screen center, tent at (-5.6, h, 3.6): is tent visible? Tent position relative to view: it's at left-near... angle: camera→tent vector = (-13.6, ?, -9.9) vs forward (-0.52,-0.83): normalized tent dir horizontal (-0.81, -0.59)?? atan2 comparisons: facing az of (-0.52,-0.83) = 238°? Using az = atan2(x, z): f: atan2(-0.52, -0.83) = -147.9° i.e., 212°. Tent dir: atan2(-13.6, -9.9) = atan2(-13.6,-9.9) ≈ -126° → 234°. Offset 22° right? Sign: 234-212 = +22° — within horizontal half-FOV 40° (landscape). So tent visible right side at 6.7 units from fire — near right edge. Hmm, and moon at 212-10.6... left-ish. Composition: moon upper-left-center, tent right, fire center. OK! Maybe move tent azimuth for balance: tent at (-6.0, 2.9)? Let me place tent at (-6.2, h, 1.2): dir from camera (14.2, 12.3 horizontal offsets): atan2(-14.2, -12.3) = -130.9° → 229°: offset +17°. Fine. Or put tent on left: tent at (-3.5, h, -6.5)? az from camera: atan2(-11.5, -20) = -150° → 210° — offset -2° → dead center behind fire?! It would overlap fire visually — actually tent behind fire partially hidden by flames... nah. Keep tent at (-6.2, ~, 1.6) right-of-center foreground. Actually wait, is it in FRONT (between camera and fire)? Camera at (8, 13.5); tent at (-6.2, 1.6): no, tent is beyond fire from camera (fire at 0,0; tent further left-back). Distance camera→tent ≈ sqrt(14.2² + 11.9² + small) ≈ 18.5 — behind fire ✓. It'll appear to the right of fire, mid-distance. 

Hmm wait, sign check: camera x=8 (east), tent x=-6.2 (west) — tent is across the fire. Screen position: with target at center, objects to the camera-relative right... camera right vector ≈ (0.847, 0, -0.53) computed earlier for similar forward. Tent offset from target: (-6.2, ?, 1.6). dot with right = -6.2*0.847 + 1.6*(-0.53) = -5.25 - 0.85 = -6.1 → LEFT side of screen, not right. Hmm let me recompute: right vector (0.847, 0, -0.53): pointing mostly +x. Camera at +x side looking toward -x. Screen-right for camera facing -x direction with up +y: facing f=(-0.52,-0.83) → right = f × up? No: right = normalize(cross(f, up))? Standard: cameraRight = cross(forward, up)?? For right-handed: right = forward × up? Let's test: forward -z (0,0,-1), up (0,1,0): forward × up = (0*0 - (-1)*1, (-1)*0 - 0*0, 0*1 - 0*0) = (1, 0, 0) = +x ✓ (screen right when looking down -z). So right = f × up = computed earlier: (-fz, 0, fx) = (0.83, 0, -0.52)→(0.847,0,-0.53). ✓. Tent offset from view center: tent - target ≈ (-6.2, 0.5, 1.6); dot right = -5.25 - 0.85 = -6.1 → tent appears LEFT of fire. And moon also slightly left. Both on left... rebalance: put tent on right side of screen: want dot(right) > 0: tent at (+x side? but camera is at +x — tent would be near camera... choose tent at (3.5, h, -6.5): offset (3.5, -, -6.5) dot right = 3.5*0.847 + (-6.5)(-0.53) = 2.96 + 3.45 = +6.4 → right side. Distance from fire: sqrt(12.25+42.25)=7.4 — fine. From camera: (8-3.5, 13.5+6.5) → dist ≈ sqrt(4.5² + 20² + ...) ≈ 20.5 — beyond fire ✓, appears right side. Tent at (3.8, h, -6.8), rotation facing fire: rotation.y = atan2(0-3.8, 0-(-6.8)) = atan2(-3.8, 6.8) ≈ -0.51 rad. Fine.

Firewood pile near tent-fire path: at (2.2, h, -3.6)? Between fire and tent ✓ visible right side. Watch it doesn't block: fine.

Composition final: moon upper-left, tent right, benches/logs lower, trees ring, fire center with glow. 

Bench logs placement: two at radius ~2.6-3: place at azimuth ~ camera side and opposite? They're low; fine anywhere. Put at az 200° and 20°... just random-ish: bench1 at (-2.2, h, 1.8)? distance 2.84 ok; bench2 at (2.4, h, -1.5) dist 2.8. Both low, won't block. Orientation: perpendicular to radius (tangential): rotation.y = az + π/2.

**Now, the autoRotate:** rotates camera azimuth around target; starting view has moon left — autorotate slowly changes composition; nice for demo. autoRotateSpeed = -0.4 (direction whatever).

**Antialiasing:** renderer antialias true; pixelRatio min(dpr, 2).

**Let me now write out all shaders fully & carefully.**

Sky:

```js
const skyMat = new THREE.ShaderMaterial({
  side: THREE.BackSide,
  depthWrite: false,
  fog: false,
  uniforms: {
    uHorizon: { value: fogColor },
    uZenith: { value: new THREE.Color(0x020711) },
    uGround: { value: new THREE.Color(0x05070d) },
    uMoonDir: { value: moonDir.clone() },
    uMoonColor: { value: new THREE.Color(0x8fa8d8) },
  },
  vertexShader: `
    varying vec3 vDir;
    void main() {
      vDir = position;
      vec4 mv = modelViewMatrix * vec4(position, 1.0);
      gl_Position = projectionMatrix * mv;
    }
  `,
  fragmentShader: `
    uniform vec3 uHorizon, uZenith, uGround, uMoonDir, uMoonColor;
    varying vec3 vDir;
    void main() {
      vec3 d = normalize(vDir);
      float h = smoothstep(-0.05, 0.5, d.y);
      vec3 col = mix(uHorizon, uZenith, pow(clamp(d.y,0.0,1.0), 0.45));
      col = mix(uGround, col, smoothstep(-0.25, 0.0, d.y));
      float m = clamp(dot(d, normalize(uMoonDir)), 0.0, 1.0);
      col += uMoonColor * (pow(m, 350.0) * 0.55 + pow(m, 24.0) * 0.12);
      gl_FragColor = vec4(col, 1.0);
      #include <tonemapping_fragment>
      #include <colorspace_fragment>
    }
  `
});
```
Note: vDir = position works since sphere centered at origin of its local space and mesh not moved. ✓ (position direction = world dir as mesh at origin, no rotation.)

Zenith/horizon hue: horizon 0x0c1526? I earlier chose fog 0x0c1322. Let me set fog = 0x0d1526, horizon same. Zenith 0x020711 slightly blue. Ground(below horizon) 0x04050a (darker below since fog only affects geometry... below-horizon sky rarely visible anyway because ground plane extends to 99 units covering down to horizon... at camera height ~3, ground edge at 70-99 units subtends below horizon fully — sky below horizon barely visible. fine.)

Moon halo: pow 350 → tight glow + pow 24 broad faint. uMoonColor 0x93aade * values. Note tonemapping will compress; okay.

Hmm one issue: sky mesh with BackSide + depthWrite false and renderOrder: set `sky.renderOrder = -5`? Opaque objects sorted front-to-back; sky drawn maybe first or last among opaques; with depthWrite false and depthTest default true — if drawn last, it would overwrite? No: depthTest true → sky fragments behind already-drawn geometry are discarded (their depth 520 farther). Where nothing drawn, sky shows. But if sky drawn FIRST, fine too. depthTest true is safe either way. Set depthWrite false to be safe for later transparent stuff. Also `frustumCulled = false` not needed (sphere always intersects... camera inside sphere → bounding sphere always intersects frustum ✓).

Stars:

```js
const starMat = new THREE.ShaderMaterial({
  transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, fog: false,
  uniforms: { uTime: {value:0}, uPixelRatio: {value: renderer.getPixelRatio()} },
  vertexShader: `
    attribute float aSize; attribute float aPhase; attribute float aTw; attribute vec3 aColor;
    uniform float uTime; uniform float uPixelRatio;
    varying float vAlpha; varying vec3 vColor;
    void main(){
      vColor = aColor;
      float tw = 0.5 + 0.5 * sin(uTime * aTw + aPhase);
      vAlpha = 0.55 + 0.45 * tw;
      vec4 mv = modelViewMatrix * vec4(position, 1.0);
      gl_PointSize = aSize * uPixelRatio * (0.85 + 0.3 * tw);
      gl_Position = projectionMatrix * mv;
    }`,
  fragmentShader: `
    varying float vAlpha; varying vec3 vColor;
    void main(){
      vec2 uv = gl_PointCoord - 0.5;
      float d = length(uv) * 2.0;
      float a = smoothstep(1.0, 0.15, d) * vAlpha;
      if (a < 0.01) discard;
      gl_FragColor = vec4(vColor * a, a); // additive: color*alpha
      #include <colorspace_fragment>
    }`
});
```
Wait with AdditiveBlending (src*1 + dst*1? Additive: srcAlpha, one? THREE.AdditiveBlending = SrcAlpha, One). So gl_FragColor.rgb * srcAlpha added. If I premultiply rgb by a AND blending multiplies by alpha again → double dim. With AdditiveBlending: blending equation: src.rgb * src.a + dst. So output vec4(vColor, a) → adds vColor*a ✓. Don't premultiply. Set gl_FragColor = vec4(vColor, a).

Stars should NOT be tone mapped but colorspace convert: include colorspace_fragment only. ✓ (tonemapping would slightly dim; skip.)

Edge falloff: smoothstep(1.0, 0.15, d): at center d=0 → 1; edge d=1 → 0. ✓ soft round stars.

Star colors: pass THREE.Color values: c = new THREE.Color(0xffffff) occasionally tinted: white 70%, warm 0xffe9c4 15%, blue 0xaec6ff 15%. Slight random luminance 0.6-1.

Star distribution: 
```js
for i<900: 
 const u = Math.random(), v = Math.random();
 // uniform on sphere: 
 const theta = Math.random()*Math.PI*2;
 const y = Math.random()*0.95 + 0.03; // elevation 0..~1 but bias? uniform on hemisphere: y = Math.random(); but more stars near horizon with uniform sphere surface → use y = Math.random()**? For uniform surface: y uniform in [0,1]. That gives horizon-dense (natural!). y = 0.02 + Math.random()*0.98 → but ensure y>0.
 const rr = Math.sqrt(1 - y*y);
 pos = (cos theta * rr, y, sin theta * rr) * 480
```
Wait stars below treeline partially hidden by trees/fog? Stars are fog:false so they shine through fog — but geometry (trees, ground) occludes by depth ✓. Stars near horizon behind trees get occluded correctly. But fully-fogged distant ground silhouette vs bright stars — realistic-ish? Stars below horizon: y min 0.02 → near-horizon stars may appear "in front of" the fog band visually — slight oddity, acceptable, actually looks like stars down to treeline.

Star size: `aSize = 1.0 + Math.pow(Math.random(), 3)*2.6` few big. 

**Firefly shader:**

```js
attributes: position (anchor), aSeed (vec4)
vertex:
  uniform float uTime; uniform float uScale;
  attribute vec4 aSeed;
  varying float vA;
  void main(){
    float t = uTime;
    vec3 p = position;
    p.x += sin(t * (0.25 + aSeed.x*0.35) + aSeed.y*6.28) * (1.0 + aSeed.z*1.5);
    p.y += sin(t * (0.2 + aSeed.y*0.4) + aSeed.x*6.28) * 0.6;
    p.z += cos(t * (0.22 + aSeed.z*0.3) + aSeed.w*6.28) * (1.0 + aSeed.y*1.5);
    float blink = pow(max(sin(t*(0.6+aSeed.w*1.2) + aSeed.y*6.28), 0.0), 3.0);
    vA = blink;
    vec4 mv = modelViewMatrix * vec4(p,1.0);
    gl_PointSize = (0.05 + 0.04*blink) * uScale / -mv.z;  // ~?? 
    gl_Position = projectionMatrix * mv;
  }
fragment:
  circular falloff * vA; color warm-green (0.75, 0.9, 0.35)
```
Point size: worldSize 0.06 * uScale(≈770)/dist(12) ≈ 3.8px ✓.

**Spark shader:**

```js
attributes: aSeed (float), aSpeed, aAngle
vertex:
  uniform float uTime; uniform float uScale;
  varying float vLife;
  void main(){
    float life = fract(uTime * aSpeed + aSeed);
    vLife = life;
    float h = life * (2.2 + aSeed * 3.0);
    float r = 0.1 + life * (0.4 + fract(aSeed*7.31) * 0.8);
    float ang = aAngle + life * (fract(aSeed*3.7)-0.5) * 3.0;
    vec3 p = vec3(cos(ang)*r, 0.25 + h, sin(ang)*r);
    p.x += sin(life*24.0 + aSeed*40.0) * 0.08 * life;
    p.z += cos(life*21.0 + aSeed*35.0) * 0.08 * life;
    vec4 mv = modelViewMatrix * vec4(p, 1.0);
    float size = (0.045 + fract(aSeed*13.7)*0.03) * (1.0 - life*0.75);
    gl_PointSize = size * uScale / -mv.z;
    gl_Position = projectionMatrix * mv;
  }
fragment:
  varying float vLife;
  void main(){
    vec2 uv = gl_PointCoord - 0.5; float d = length(uv)*2.0;
    float a = smoothstep(1.0, 0.0, d);
    a *= (1.0 - smoothstep(0.6, 1.0, vLife)) * smoothstep(0.0, 0.05, vLife);
    vec3 c = mix(vec3(1.0, 0.85, 0.45), vec3(1.0, 0.25, 0.05), smoothstep(0.15, 0.8, vLife));
    gl_FragColor = vec4(c * 1.6, a);
    #include <colorspace_fragment>
  }
```

uScale shared by fireflies and sparks: compute = h / (2*tan(fov/2)) in pixels * pixelRatio? gl_PointSize is in device pixels? gl_PointSize is in pixels of drawing buffer → multiply by pixelRatio. uScale = (height_devicepixels) / (2 tan(fov/2)) where height_device = height_css * pixelRatio. On resize recompute. 

At 800 css height, dpr 1: uScale = 800/(2*0.5206)=768. ✓ as before.

**Flame material:**

```js
function makeFlameMat(phase, intensity, colA, colB){
  return new THREE.ShaderMaterial({
    transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide,
    uniforms: {
      uTime: {value:0}, uPhase: {value: phase}, uHeight: {value: 1}, uIntensity: {value: intensity},
      uColA: {value: new THREE.Color(colA)}, uColB: {value: new THREE.Color(colB)},
    },
    vertexShader: `...`, fragmentShader: `...`
  });
}
```

vertex:
```glsl
uniform float uTime, uPhase, uHeight;
attribute float aRand;
varying float vH;
void main(){
  vec3 p = position;
  float h = clamp(p.y / uHeight, 0.0, 1.0);
  vH = h;
  float t = uTime * 1.6 + uPhase * 10.0;
  float sway = sin(t * 1.7 + uPhase) * 0.18;
  float n1 = sin(t * 6.0 + p.y * 6.0 + aRand * 6.283);
  float n2 = cos(t * 4.7 + p.y * 4.2 + aRand * 6.283);
  p.x += (sway + n1 * 0.12) * h * h;
  p.z += (cos(t * 1.3 + uPhase) * 0.15 + n2 * 0.12) * h * h;
  p.y *= 1.0 + 0.08 * sin(t * 5.0 + uPhase + aRand);
  gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}
```
Hmm sway*h*h keeps base anchored. But h*h at mid small... flames wobble mostly upper half — good.

fragment:
```glsl
uniform vec3 uColA, uColB; uniform float uIntensity;
varying float vH;
void main(){
  vec3 col = mix(uColA, uColB, smoothstep(0.08, 0.75, vH));
  float a = (1.0 - smoothstep(0.35, 1.0, vH));
  a = pow(a, 1.3) * 0.9;
  gl_FragColor = vec4(col * uIntensity, a);
  #include <colorspace_fragment>
}
```
uColA bright yellow-white linear-ish (0xffcf66 → as Color hex auto-linear), uColB deep orange-red 0xff3600? For linear + additive stacking it'll look hot. uIntensity ~1.6-2.2.

Since additive with THREE.AdditiveBlending (srcAlpha, One): contribution = col * a. ✓

Also cones: with side: DoubleSide and additive, back faces add too → brighter center, nice.

aRand attribute: Float32Array with random per vertex.

Flame meshes:
```js
const flameDefs = [
  { r: 0.62, h: 1.75, y: 0.05, phase: 0.0, i: 1.5, colA: 0xffb84d? ...},
  { r: 0.42, h: 1.35, y: 0.10, phase: 2.1, ... },
  { r: 0.26, h: 0.95, y: 0.08, phase: 4.2, colA: 0xfff3b0 (hot core) },
  { r: 0.85, h: 0.55, y: 0.02, phase: 1.2, wider base flame }
];
```
Each: ConeGeometry(r, h, 7, 4); translate(0, h/2, 0); mesh at (0, y, 0), slight rotation.y random. Scale flicker in tick: mesh.scale.y = 1 + 0.07*sin(t*9+phase); mesh.rotation.y += dt*0.6? slow rotation for cone vertex shimmer — subtle. Also scale.x/z inverse breathing: 1 - 0.04*sin(...). 

Cone with 7 radial segments & 4 height segments: heightSegments on ConeGeometry param 4 ✓, plus openEnded default false (tip cap fine).

Flames group position y=0.12 overall.

**Embers:** 7 small TetrahedronGeometry(0.16, 0) or DodecahedronGeometry(0.14, 0) scattered r<0.5, y=0.06, MeshStandardMaterial({color 0x150a05, emissive 0xff5a1e, emissiveIntensity 2, flatShading}). Shared material for pulse. castShadow false (they'd block light weirdly) — set false. Also ember glow ring: circle geometry on ground under fire with additive gradient? The glow sprite covers it. Skip.

**Glow sprite:** canvas 256 radial: stops: 0: rgba(255,180,90,0.9), 0.25: rgba(255,120,40,0.45), 0.6: rgba(255,80,20,0.12), 1: transparent. SpriteMaterial({map, color 0xffffff, blending Additive, depthWrite false, transparent}). scale base 5.5. Position (0, 1.0, 0). Pulse with flicker. Actually two sprites: tight bright small (2.2) + broad faint (6)? One is fine with gradient shape.

Smoke texture canvas: radial rgba(200,190,180,0.5)→0.

Smoke update:
```js
const smokes = [];
for (let i=0;i<7;i++){
  const s = new THREE.Sprite(smokeMat.clone()); // clone for independent opacity
  s.userData = { seed: Math.random(), speed: 0.05 + Math.random()*0.04, x: (Math.random()-0.5)*0.4, z: (Math.random()-0.5)*0.4 };
  scene.add(s); smokes.push(s);
}
tick:
  for s: const life = (t*speed + seed) % 1;
  s.position.set(x + life*0.8 + sin(t*0.5+seed*10)*0.3, 0.7 + life*5.2, z + life*0.6);
  const sc = 0.7 + life*3.8; s.scale.set(sc, sc, 1);
  s.material.opacity = Math.sin(life*Math.PI) * 0.10;
```
Sprites with NormalBlending, color 0x706a63? Let me color slightly warm-gray 0x8a8178, opacity 0.09 max. depthWrite false. Might look like faint haze rising — good.

Sprite clones: 7 materials — fine.

**Moon glow sprite:** canvas radial white-blue: 0: rgba(235,240,255,0.85), 0.2: rgba(190,205,240,0.35), 0.55: rgba(140,160,220,0.08), 1: 0. scale 90 (moon at 470, sprite scale 90 → angular ~11° halo). position moonDir*468 (slightly behind moon mesh at 470? Draw order for transparency by depth: sprite at 468 closer than moon mesh 470 → drawn after... additive glow overlapping moon disc fine either way.)

Moon mesh: IcosahedronGeometry(15, 1)? angular size: 2*atan(15/470) ≈ 3.6° — nice stylized. MeshBasicMaterial({color 0xdfe9f7, fog: false}). Low-poly facets invisible with basic... Use MeshStandardMaterial with emissive? Lit by scene lights? Directional light comes FROM moon direction → moon's lit side faces away-ish... Simplest good look: MeshBasicMaterial but add "craters" via vertex colors? Skip craters; stylized flat moon disc reads fine with glow + halo. Maybe slight tint variation via a second smaller darker circle offset? Nah — clean disc + halo looks intentional.

Hmm — could make the moon a nice low-poly with flatShading + MeshStandardMaterial emissive 0xc9d6ee emissiveIntensity 0.9 color 0x0b0d12 and let hemisphere light add facet shading... flatShading with only hemisphere (no directional from front) gives per-face variation? Flat shading depends on normals vs lights; hemisphere light gives gradient by normal direction — subtle facet variation. Eh. Basic is crisp. Go Basic, but then it's a flat silhouette circle-ish (icosa detail 1 → nearly round with slight facets ✓ low-poly vibe).

Also — stars near moon washed by additive halo ✓ nice.

**Tent colors:** canvas 0x8a6a4a? At night lit by hemisphere+moon dim: appears dark tan. Firelight from center warms the side facing fire ✓. flatShading. Add slight roughness 0.9. Tent castShadow + receiveShadow ✓. Opening dark: 0x060404 Basic? Use MeshStandardMaterial color 0x050303 (unlit-ish dark) — but standard dark object still gets firelight on the front-facing... opening faces fire → firelight would light the dark plane making it visible brown. Use MeshBasicMaterial black-ish 0x070503 (unlit, always near-black) → reads as dark opening ✓.

Tent geometry: ExtrudeGeometry triangle: depth 2.6. UVs irrelevant (solid color). flatShading on extrude fine. But extrude has side + caps; normals fine. Position tent on ground: base y0 at groundHeight(x,z). Slight sink -0.05. Also add a thin "ridge seam"? skip.

Also add tiny warm light inside tent? No.

Also maybe a second small detail: a hanging lantern? Scope control — no.

**Grass tufts:** Actually, let me add low-poly grass patches as instanced cones — 200 tiny dark-green tetrahedra/cones (h 0.25-0.5) scattered radius 2.5-14 clearing edge? "Clearing" = grassy. InstancedMesh of ConeGeometry(0.07, 0.45, 4) with per-instance color dark green 0x2c3b22 varied, random tilt. 250 instances, 1 draw call. castShadow false (avoid shadow noise), receiveShadow false. Cheap and adds texture to foreground. Yes, include: positions radius 2.2..15 (avoid dirt near fire r<2? dirt patch 2.5 — grass from r 2.8 outward to 15). Ground height for y. Sink 0.03. 

Actually shadow casting for grass off ✓, but grass receiving fire shadows would be nice — receiveShadow true causes acne? Instanced cones receiving shadow OK with bias. Let me set receiveShadow true, castShadow false. If acne appears on tiny cones... normalBias on point light set 0.02... risky but grass is tiny. I'll set receiveShadow false to be safe — grass unshadowed is fine at night.

**Numbers audit (draw calls):** ground 1, grass 1, trunksPine 1, cones 3, trunksDecid 1, blobs 1, ring stones 9, scattered rocks ~10, boulders 3, fallen logs 3, benches 2, teepee logs 5, firewood 5, embers 7, tent 2, flames 4, sprites (glow, moonglow, smoke 7), points (stars, sparks, fireflies), sky, moon. ≈ 60 draw calls + shadow passes (point cube = 6 × casters ~40 + dir 1 × ~40). Fine.

**Shadow casters:** trees (instanced) cast, rocks cast, logs cast, tent casts, teepee logs cast, flames don't (ShaderMaterial transparent — set castShadow false default ✓), embers no, grass no, ground receive only (castShadow false — else ground self? ground shouldn't cast). 

Point light shadow includes ground? Ground castShadow=false ✓ good.

**Now — tone check for screenshot at 3s:** Scene dark but fire bright. Ensure ground near fire clearly lit warm; camera close enough (15.7 dist) to see details. Exposure 1.2. Hemisphere intensity: let me bump moon dir light 0.55 and hemisphere 0.8 to ensure trees visible as silhouettes with rim... rather than pitch black. With ACES: dir 0.55 * albedo 0.15... trees dark green reflect little; they'll read as dark shapes against slightly lighter sky + fog band. Good — nighttime realistic; fire pops.

Let me finalize light values:
- HemisphereLight(0x33456b, 0x1a140f, 0.9)? Hmm physical units — hemisphere intensity 0.9 with ACES gives visible blue-ish fill. I'll go 0.7.
- DirectionalLight(0x9fb8e8, 0.6) from moonDir*100. Long shadows. Slight blue.
- Fire PointLight(0xff9040, 220, 60, 2).
- Also faint warm secondary light low: skip.

Ground albedo colors ~0x2a3a26 (dark). Firelight at 3 units: 220/9 = 24 → ACES maps to bright warm ✓.

**Controls note:** `controls.target.set(0, 1.0, 0)`.

maxPolarAngle: Math.PI/2 - 0.06 ≈ 1.51. At minDistance 4.5: camera y ≥ 1.0 + 4.5*cos(1.51) = 1.0+0.27 = 1.27. Ground under camera at horizontal dist 4.49: groundHeight ≈ ±0.8 max... uh oh: at (4.49, y=1.27), ground could be up to ~0.9 there? groundHeight amplitude: sin products sum max ~1.48*flat(4.49→smoothstep(4.49,2.5,12)=0.24)→ ~0.35. So ground ≤0.35 < 1.27 ✓. At maxDistance 26 horizontal 26: groundHeight max: amplitude ~1.48 * 1 = 1.48?? At r=26: h could be 1.4. Camera y at polar 1.51: 1.0 + 26*0.0615 = 2.6 ✓ > 1.5. OK safe. But also camera at horizontal 26 might intersect trees (trees at r≥16, height up to ~5.8, camera y 2.6) — camera could clip through foliage! maxDistance 26 with trees 16-48 → camera orbit at 26 passes within tree zone. Options: keep maxDistance 24 and trees from 18? Camera orbit radius 24 at y~2.6: trees at 18-24 could intersect. Hmm. Mitigation: it's acceptable in demos (brief clipping), but let's minimize: trees min radius 17, camera maxDistance 22, minDistance 4.5, camera height at max polar 1.0+22*0.0615 = 2.35; trees at 17-22 zone could still overlap camera path... Reduce risk: make inner tree ring (17-22) have lower height trees? Simpler: accept minor clipping (OrbitControls standard) — most scenes accept it. Also fog: camera at 22 fine.

Alternatively push trees to ≥ 20 and maxDistance 19? That makes view restrictive... Let me choose: trees 18..50, camera maxDistance 21, min 5. At distance 21, y min 2.29; trees at 18-21 possible collision only at exact azimuth. It's fine — even AAA demos do this. Actually initial camera distance 15.5 (< 18) ✓ clean view.

Hmm wait, requirement says trees "around the clearing... not on the fire" — clearing radius 18 with camera max 21 might show sparse near sides when orbiting far. It's fine.

Tree count 52 within 18-50 annulus (area ~π(2500-324)= ~6800, tree spacing ~2.6 min → plenty).

Let me also add a handful of VERY distant giant silhouetted trees? No — fog covers 80. Fine.

**One more check — "renderer fills the window and handles resizing":** standard ✓. Also handle `window.addEventListener('resize')` update uScale, uPixelRatio.

**Fog check with fire glow:** ground far → fog color; near fire bright. ✓

**Text overlay design:** 

Title: "EMBERWOOD" or "The Night Camp"? Something evocative: "EMBERFALL — a night in the clearing". Keep classy: title "Emberwood" letterspaced; subtitle "low-poly night camp · three.js". Hint pill: "drag to orbit · scroll to zoom". Vignette. Also small "auto-orbit" note that disappears? On 'start' event stop autorotate + fade hint. 

Also maybe crosshair-free. Fine.

Loading element: absolutely centered small text "lighting the fire…", remove after first render.

Edge: WebGL not available? Ignore (sandbox supports).

**Write the code now.** I'll be careful with:
- Module import + importmap ✓ exact URL given.
- No localStorage etc ✓.
- Everything inline ✓.

Also careful: `THREE.MathUtils.smoothstep(x, min, max)` — verify signature: MathUtils.smoothstep(x, min, max) returns 0 if x<min... ✓.

Colors: I'll define palette constants.

Let me carefully write ground vertex colors:

```js
const groundGeo = new THREE.PlaneGeometry(150, 150, 80, 80);
groundGeo.rotateX(-Math.PI / 2);
const pos = groundGeo.attributes.position;
const colArr = new Float32Array(pos.count * 3);
const cGrass1 = new THREE.Color(0x1f2e1a);
const cGrass2 = new THREE.Color(0x2f4023);
const cDirt = new THREE.Color(0x3c2d1c);
const cDark = new THREE.Color(0x16200f);
const c = new THREE.Color();
for (let i = 0; i < pos.count; i++){
  const x = pos.getX(i), z = pos.getZ(i);
  const y = groundHeight(x, z);
  pos.setY(i, y);
  const d = Math.hypot(x, z);
  const n = 0.5 + 0.5 * Math.sin(x * 0.35 + 2.1) * Math.cos(z * 0.31 + 0.7); // 0..1-ish (can exceed; clamp)
  const n2 = 0.5 + 0.5 * Math.sin(x * 0.83 + 5.0) * Math.sin(z * 0.91 + 1.3);
  c.copy(cGrass1).lerp(cGrass2, THREE.MathUtils.clamp(n, 0, 1));
  c.lerp(cDark, THREE.MathUtils.clamp(n2 * 0.5, 0, 0.5));
  const dirt = 1 - THREE.MathUtils.smoothstep(d, 1.2, 3.4); // 1 near fire
  c.lerp(cDirt, dirt * 0.9);
  colArr[i*3] = c.r; colArr[i*3+1] = c.g; colArr[i*3+2] = c.b;
}
groundGeo.setAttribute('color', new THREE.BufferAttribute(colArr, 3));
groundGeo.computeVertexNormals();
const groundMat = new THREE.MeshStandardMaterial({ vertexColors: true, flatShading: true, roughness: 1, metalness: 0 });
const ground = new THREE.Mesh(groundGeo, groundMat);
ground.receiveShadow = true;
```

80×80 segments = 6400 quads = 12800 tris ✓ ok.

Plane 150: half-diagonal 106 > fog far ✓ edges fully fogged.

**Rock creation with welded jitter:**

```js
function makeRock(size, color){
  const geo = new THREE.IcosahedronGeometry(size, 0);
  const p = geo.attributes.position;
  const v = new THREE.Vector3();
  const map = new Map();
  for (let i = 0; i < p.count; i++){
    v.fromBufferAttribute(p, i);
    const key = `${v.x.toFixed(3)}_${v.y.toFixed(3)}_${v.z.toFixed(3)}`;
    let off = map.get(key);
    if (!off){ off = [(Math.random()-0.5)*size*0.55, (Math.random()-0.5)*size*0.5, (Math.random()-0.5)*size*0.55]; map.set(key, off); }
    v.x += off[0]; v.y += off[1] * 0.7; v.z += off[2];
    p.setXYZ(i, v.x, v.y, v.z);
  }
  geo.computeVertexNormals(); // (flatShading overrides anyway)
  const mat = new THREE.MeshStandardMaterial({ color, flatShading: true, roughness: 0.95 });
  const m = new THREE.Mesh(geo, mat);
  m.castShadow = true; m.receiveShadow = true;
  return m;
}
```
Note Icosahedron detail 0 — is it indexed with shared vertices? PolyhedronGeometry builds non-indexed (positions duplicated per face). With position-keyed offsets, identical original positions get identical offsets → welded visually ✓.

Place scattered rocks:

```js
const rockDefs = [];
for (let i=0;i<9;i++){ random angle, r = 3 + Math.random()*8; size 0.25+rand*0.5 }
plus boulders: 3 at r 9-14, size 1.1+rand*0.8
y = groundHeight - size*0.25 (sink)
```
Sink: rocks' geometry centered; sink by size*0.3 so they sit in ground.

Ring stones: 9 small rocks size 0.16-0.24 at r 1.05 + jitter, y = 0.02 (ground height ~0 there) — sink 0.05.

**Logs:**

```js
const barkMat = new THREE.MeshStandardMaterial({ color: 0x4a3524, flatShading: true, roughness: 1 });
function makeLog(len, rad){
  const geo = new THREE.CylinderGeometry(rad*0.85, rad, len, 6);
  const m = new THREE.Mesh(geo, barkMat);
  ...
}
```
Fallen logs: rotation.z = PI/2 → lies along x; then rotation.y = yaw. Set y = groundHeight + rad*0.75. Random. End caps: cylinder has caps ✓. Slight tilt random rotation.x small.

Bench logs: radius 0.22, len 2.3, yaw tangential, y = groundHeight(x,z)+0.2.

Firewood pile: group of 5 short logs (r 0.085, len 1.15): three on ground side by side (offset z -0.1, 0, +0.1... in local group: positions (0, 0.08, ±0.1)), two on top (0, 0.22, ±0.05), rotated all along x-axis (rotation.z = PI/2), group rotated y random, placed near (2.3, h, -3.4). castShadow each.

**Teepee logs:**
```js
const teepeeGeo = new THREE.CylinderGeometry(0.07, 0.1, 1.5, 6);
teepeeGeo.rotateX(Math.PI/2);
teepeeGeo.translate(0, 0, 0.75); // base at origin extends +z
for (let i=0;i<5;i++){
  const a = (i/5)*Math.PI*2 + 0.4;
  const m = new THREE.Mesh(teepeeGeo, charredMat);
  m.position.set(Math.cos(a)*0.55, 0.1, Math.sin(a)*0.55);
  m.lookAt(0, 1.25, 0);
  m.castShadow = true;
  fireGroup.add(m);
}
```
charredMat: color 0x2a1a10 flat roughness 1.

Wait — lookAt target (0,1.25,0) from position at (0.55cos, 0.1, 0.55sin): direction length sqrt(0.55²+1.15²)=1.27; log length 1.5 extends past center → tips cross above center at ~1.25 height... tops converge near target ✓ teepee look.

**Trees code:**

```js
const treeSpots = [];
let guard = 0;
while (treeSpots.length < 52 && guard++ < 2000){
  const a = Math.random() * Math.PI * 2;
  const r = 18 + Math.sqrt(Math.random()) * 32; // 18..50
  const x = Math.cos(a)*r, z = Math.sin(a)*r;
  let ok = true;
  for (const p of treeSpots){
    const dx = p.x-x, dz = p.z-z;
    if (dx*dx + dz*dz < 2.6*2.6){ ok = false; break; }
  }
  if (ok) treeSpots.push({x, z, pine: Math.random() < 0.72, s: 0.75 + Math.random()*0.7, rot: Math.random()*Math.PI*2});
}
```
Split lists pines/deciduous. Then build instanced meshes sized to counts.

Pine parts (geometry pre-translated):
```js
pineTrunkGeo = CylinderGeometry(0.14, 0.22, 1.7, 5); translate(0, 0.85, 0);
coneGeo1 = ConeGeometry(1.3, 1.9, 7); translate(0, 0.95, 0); // base at 0
coneGeo2 = ConeGeometry(0.95, 1.6, 7); translate(0, 0.8, 0);
coneGeo3 = ConeGeometry(0.6, 1.35, 7); translate(0, 0.675, 0);
levels: y offsets [0.5, 1.5, 2.45]
```
Total height ≈ 2.45+1.35 = 3.8 × s(0.75-1.45) → 2.9-5.5 ✓.

Matrices per pine i:
```js
base matrix M = compose(pos(x, y, z), quatY(rot), scale(s, s*heightJitter, s))
trunk: M * identity
level k: M * compose((0, yOff_k, 0), identity, (1,1,1))
```
Wait cones should also sway? no.

Scale jitter per tree: s uniform + height factor 0.9-1.15.

Colors:
```js
trunk instance colors: base 0x3e2c1e lerp toward 0x5a4028 random.
foliage: pick from palette [0x1d3a24, 0x225129, 0x18321f, 0x1f4431] + HSL jitter small.
```
Use `color.offsetHSL((Math.random()-0.5)*0.03, (Math.random()-0.5)*0.15, (Math.random()-0.5)*0.06)`.

Same color for all 3 levels per tree (slight per-level lightness ramp: lower darker: multiplyScalar? Level colors: c, c*0.92, c*1.08 for depth).

Deciduous parts:
```js
dTrunkGeo = CylinderGeometry(0.16, 0.26, 2.2, 5); translate(0,1.1,0);
blobGeo = IcosahedronGeometry(1.15, 0);
per tree: blob1 local pos (0, 2.6, 0) scale (1, 0.85, 1); blob2 pos (0.65, 2.1, 0.35) scale 0.62; blob3 maybe (-0.5,2.3,-0.3) scale 0.5 → use 3 blobs: instanced count = 3*nDecid.
```
Colors: 0x2a4527, 0x24402c, 0x2f4a22 jitter.

InstancedMesh construction:

```js
function makeInstanced(geo, mat, count){ const im = new THREE.InstancedMesh(geo, mat, count); im.castShadow = true; return im; }
```
Set matrices:

```js
const dum = new THREE.Object3D();
const mtx = new THREE.Matrix4(), localM = new THREE.Matrix4();
pines.forEach((p, i) => {
  const y = groundHeight(p.x, p.z) - 0.06;
  dum.position.set(p.x, y, p.z);
  dum.rotation.set(0, p.rot, 0);
  dum.scale.set(p.s, p.s*p.hj, p.s);
  dum.updateMatrix();
  trunkIM.setMatrixAt(i, dum.matrix);
  levels.forEach((lv, k) => {
    localM.makeTranslation(0, lv.y, 0);
    mtx.copy(dum.matrix).multiply(localM);
    coneIMs[k].setMatrixAt(i, mtx);
  });
  trunkIM.setColorAt(i, trunkColor);
  coneIMs[k].setColorAt(i, levelColor(k));
});
```

After: instanceMatrix.needsUpdate = true; if(instanceColor) needsUpdate.

Set `frustumCulled` default true fine (bounding sphere computed from geometry — instanced culling uses whole geometry bounds, wrong for spread instances → set im.frustumCulled = false to avoid disappearing! Important: InstancedMesh bounding sphere is geometry's (small) — three culls by geometry bounds transformed by mesh matrix → all instances vanish when camera looks away from origin-ish bound. Actually InstancedMesh has computeBoundingSphere that accounts instances (r160+? InstancedMesh.computeBoundingSphere exists and is called in raycast, but frustum culling uses boundingSphere of geometry? Mesh.frustumCulled uses geometry.boundingSphere transformed. For InstancedMesh, three r170: WebGLRenderer frustum culling checks object.boundingSphere? projectObject → if (object.isMesh... ) frustum.intersectsObject(object) → intersectsObject uses object.boundingSphere ?? For InstancedMesh, intersectsObject(object) uses object.boundingSphere if defined? Let me recall: Frustum.intersectsObject: `const geometry = object.geometry; if (object.boundingSphere !== undefined) { if (object.boundingSphere === null) object.computeBoundingSphere(); sphere.copy(object.boundingSphere)... } else { if (geometry.boundingSphere === null) geometry.computeBoundingSphere(); ... }`. InstancedMesh.boundingSphere exists (initialized null) and computeBoundingSphere() covers instances ✓ (added r156-ish). So r170 auto-computes instance-aware bounds ✓ no culling problem. But safer: set frustumCulled = false on instanced meshes (scene always mostly in view anyway). I'll set false to be extra safe.

**InstancedMesh + setColorAt before first render** — fine.

**Point light shadow with instanced trees:** ✓ supported.

**Camera & controls:**

```js
const camera = new THREE.PerspectiveCamera(55, innerWidth/innerHeight, 0.1, 1500);
camera.position.set(8.5, 3.0, 14);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1.0, 0);
controls.enableDamping = true; controls.dampingFactor = 0.06;
controls.minDistance = 5; controls.maxDistance = 21;
controls.maxPolarAngle = Math.PI/2 - 0.07;
controls.minPolarAngle = 0.12;
controls.enablePan = false;
controls.autoRotate = true; controls.autoRotateSpeed = -0.45;
controls.addEventListener('start', () => { controls.autoRotate = false; hint fade });
```
autoRotate requires controls.update each frame ✓ (we call update with damping anyway).

Initial camera distance: (8.5,3,14) from target (0,1,0): d = sqrt(72.25+4+196) = sqrt(272) ≈ 16.5 ✓ within 5-21. Elevation angle: (3-1)/16.5 → ~7° above target — low, near-horizontal view: fire glowing center, trees ring, moon above ✓. polar angle ≈ 90-7 = 83°?? maxPolarAngle is 1.50 (86°)... polar measured from +y axis: camera at y 3, target 1 → above target → polar < 90°. polar = acos(2/16.5) = 83° ✓ under max 86 ✓.

Hmm wait — with such a low camera, ground fills lower frame at close range... camera y=3 above ground (~0 near center ±0.3) → looks fine, slight downward angle shows fire + ring + ground.

Moon visibility recheck with these numbers: camera (8.5, 3, 14), target (0,1,0): f = (-8.5, -2, -14)/16.6 → (-0.512, -0.12, -0.843). Pitch down = atan(2/16.5) ≈ 6.9°. Moon elevation 17.4° → apparent ≈ 24.3° above center vs half-FOV 27.5 ✓ visible near top, slightly left (horizontal offset ~10° < 40 ✓). 

For 375×667 portrait: horizontal half-FOV = atan(tan(27.5°)*(375/667)) = atan(0.52*0.562) = atan(0.292) = 16.3° → moon at 10° horizontal offset ✓ still in frame! vertical: half-FOV 27.5 ✓. Great — moon visible even mobile. Fire centered ✓.

**Fire group assembly:** all fire parts in `fire` group at origin (ground ~0). 

**One more component — "logs around campfire" requirement** — benches + fallen logs ✓ plus teepee ✓.

**Star of the show — flame color values:**
- uColA: 0xffd9a0? bright yellowish. Let me: outer flames colA 0xffc266 colB 0xff4a00, uIntensity 1.7.
- core flame colA 0xfff2c8 colB 0xff9a33, uIntensity 2.6.
- base wide flame colA 0xff9a3c colB 0xd42b00, uIntensity 1.2.
Colors passed as THREE.Color(hex) → linear internal; colorspace chunk converts back ✓ hues as authored.

**Flicker & light color:** light color 0xff9243? warm orange 0xff8c3a. 

Let me also add subtle blue rim light opposite moon? Hemisphere covers.

**Ember material pulse** and flame uTime update.

**Sparks uTime.**

**Star uTime (twinkle).** slow.

**Fireflies uTime.**

OK — also, one risk: `#include <colorspace_fragment>` inside ShaderMaterial — chunk uses `gl_FragColor` — in r170 chunk content: `gl_FragColor = linearToOutputTexel( gl_FragColor );` ✓ compiles in ShaderMaterial (function defined in common header injected always? `linearToOutputTexel` is defined in the shader prefix for all programs? The function comes from `getTexelEncodingFunction`? In newer three, `linearToOutputTexel` is injected in the fragment prefix for ALL materials — check: WebGLProgram adds `getTexelEncodingFunction('linearToOutputTexel', parameters.outputColorSpace)` to fragment prefix unconditionally? It's added when `parameters.outputColorSpace`... Looking at three r170 WebGLProgram: prefixFragment includes `getTexelEncodingFunction( 'linearToOutputTexel', parameters.outputColorSpace ),` — yes, always included in prefix for non-raw shader materials. ✓ And `#include <tonemapping_fragment>` uses `toneMapping( color )`... chunk: `#if defined( TONE_MAPPING ) gl_FragColor.rgb = toneMapping( gl_FragColor.rgb ); #endif` — TONE_MAPPING define set only when material.toneMapped && renderer.toneMapping ≠ None ✓ compiles fine even if undefined.

Safe ✓.

**Also `attribute vec3 aColor`** — attribute name 'color' is reserved-ish if vertexColors false... I named aColor ✓ fine. Or use built-in 'color' attribute with vertexColors true. Keep aColor.

**Potential warning:** ShaderMaterial with attribute declared but geometry missing → error. Ensure all attributes provided.

**Sprites and fog:** SpriteMaterial({fog: false}) for glow & moonglow; smoke sprites fog:false too (they're close, no fog anyway).

**Now resize:**

```js
function onResize(){
  const w = innerWidth, h = innerHeight;
  camera.aspect = w/h; camera.updateProjectionMatrix();
  renderer.setSize(w, h);
  const pr = Math.min(devicePixelRatio, 2);
  renderer.setPixelRatio(pr);
  pointScale = (h * pr) / (2 * Math.tan(camera.fov * Math.PI/360));
  update uniforms uScale (sparks, fireflies), uPixelRatio (stars);
}
```
Wait gl_PointSize in device pixels: buffer height = h*pr → uScale = h*pr/(2 tan) ✓.

**Animation order:** compute dt, t; update uniforms; flicker; smoke; controls.update(); render.

**Hint fade:** 
```js
const hint = document.getElementById('hint');
controls.addEventListener('start', () => { controls.autoRotate = false; hint.classList.add('hide'); }, { once? } )
```
OrbitControls event 'start' fires per interaction; use flag.

Also on touch devices 'start' fires on touch-drag ✓.

**Title styles:** 

```css
.brand { position: fixed; top: 26px; left: 30px; z-index: 5; pointer-events: none; color: #e9dcbc; }
.brand h1 { margin: 0; font: 400 15px/1 "Georgia", "Times New Roman", serif; letter-spacing: 0.5em; text-transform: uppercase; text-shadow: 0 0 24px rgba(255,150,60,.35); }
.brand p { margin: 7px 0 0; font: 400 10px/1 system stack; letter-spacing: .28em; text-transform: uppercase; color: rgba(210,190,150,.55); }
```
Small + tasteful. Also a flame SVG? Add small inline SVG flame before title — I'll include a minimal one:

```html
<svg width="14" height="16" viewBox="0 0 14 16" fill="none"><path d="M7 0C8 3 12 5 12 9.5C12 13 9.9 16 7 16C4.1 16 2 13 2 9.5C2 7.5 3 6 4.5 4.5C4.7 6.4 5.4 7.2 6.3 7.6C5.9 5 6.4 2 7 0Z" fill="url(#g)"/><defs><linearGradient id="g" x1="7" y1="16" x2="7" y2="0"><stop stop-color="#ff7a1a"/><stop offset="1" stop-color="#ffd873"/></linearGradient></defs></svg>
```
inline in the brand div ✓.

Hint:
```css
#hint { position: fixed; left: 50%; bottom: 30px; transform: translateX(-50%); z-index:5; pointer-events:none;
  font: 400 11px/1 ...; letter-spacing: .3em; text-transform: uppercase; color: rgba(230,215,180,.75);
  padding: 10px 18px; border: 1px solid rgba(230,200,150,.18); border-radius: 999px; background: rgba(8,10,16,.45); backdrop-filter: blur(6px);
  transition: opacity .8s ease; }
#hint.hide { opacity: 0; }
```
Content: "drag to orbit · scroll to zoom". Note "·" fine.

Vignette:
```css
#vignette { position: fixed; inset: 0; z-index: 4; pointer-events: none;
  background: radial-gradient(130% 100% at 50% 42%, transparent 50%, rgba(1,3,8,.55) 100%); }
```

Loading:
```css
#loading { position: fixed; inset:0; display:flex; align-items:center; justify-content:center; z-index: 9; color: rgba(220,200,160,.8); font ... ; letter-spacing: .35em; text-transform: uppercase; font-size: 11px; transition: opacity .6s; background: #04060c; }
```
After first render: opacity 0 then display none via transitionend or just remove node. But if module fails to load (CDN blocked?) loading stays — acceptable. Remove after first frame: `loading.remove()` after init (called before first render or after). I'll fade: set style.opacity=0 then setTimeout remove 700ms.

Hmm — screenshot at ~3s: CDN module load typically <300ms. Fine.

**Edge: WebGL context creation inside iframe** ✓.

**Let me also double check OrbitControls import path:** 'three/addons/controls/OrbitControls.js' with importmap "three/addons/": ".../examples/jsm/" ✓ resolves to examples/jsm/controls/OrbitControls.js ✓ and its internal `import * as THREE from 'three'`? OrbitControls in r170 imports from 'three' ✓ mapped ✓.

**Assemble tick():**

```js
let elapsed = 0;
function animate(){
  requestAnimationFrame(animate);
  const dt = Math.min(clock.getDelta(), 0.05);
  elapsed += dt;
  const t = elapsed;
  // flicker
  const fl = Math.sin(t*8.7)*0.5 + Math.sin(t*22.3+1.7)*0.22 + Math.sin(t*3.9+0.4)*0.28 + Math.sin(t*14.1+2.9)*0.18;
  fireLight.intensity = 230 * (1 + 0.13*fl);
  fireLight.position.set(Math.sin(t*13.7)*0.08, 1.25 + Math.sin(t*9.1)*0.05, Math.cos(t*11.9)*0.08);
  emberMat.emissiveIntensity = 1.7 + fl*0.9;
  glow.material.opacity = 0.30 + fl*0.05;
  const gs = 5.6 + fl*0.5; glow.scale.set(gs, gs, 1);
  flameMats.forEach(m => m.uniforms.uTime.value = t);
  flames.forEach((f, i) => { f.rotation.y += dt*(0.5+i*0.2); f.scale.y = 1 + Math.sin(t*9.3+f.userData.phase)*0.06; });
  sparkMat.uniforms.uTime.value = t;
  starMat.uniforms.uTime.value = t;
  flyMat.uniforms.uTime.value = t;
  // smoke
  for (const s of smoke){ ... }
  controls.update();
  renderer.render(scene, camera);
}
```

Note: flame mesh rotation.y on top of lookAt? flames are standalone cones (not lookAt) — fine.

flame scale.y applied — geometry base at y=0 local (translated) so scaling stretches upward from base ✓ mesh positioned at y=0.12.

**Fireflies alpha:** blink pow — many invisible at once is fine (they blink asynchronously).

Also give fireflies slight color variety attribute? Single color ok (0.65, 0.85, 0.3) linear-ish → greenish-yellow.

**Moon glow + moon + sky render order:** transparent objects sorted by depth — moon (opaque basic, drawn in opaque pass) ✓; moonglow sprite transparent at 468 vs sky opaque at 520 — opaque sky drawn first, sprite after ✓. Stars transparent additive at 480: drawn after sky ✓ before/after moonglow by distance sort ✓.

Wait — sky is opaque (transparent false) drawn in opaque pass; stars additive drawn transparent pass after ✓. 

**Check: stars drawn before smoke? Transparent sorted far→near: stars (480) first, moonglow 468, smoke ~20, glow sprite ~16... additive order irrelevant-ish ✓.

**Ground plane & fog band:** ✓.

**Fog color 0x0d1526 vs sky horizon:** same value → blend ✓. But sky applies tonemapping; ground fog also tonemapped ✓ match. Star fog none ✓. Moon fog false ✓.

One subtlety: distant ground fully fogged → renders fogColor tonemapped; sky horizon at same view direction: mix(horizon...) with halo maybe brighter near moon azimuth. Ground fog color constant → slight mismatch where halo brightens horizon above treeline — halo is above horizon mostly (moon elevation 17°) ✓ subtle.

**Ember shadows off, glow sprite no shadow ✓.**

**Also set renderer.shadowMap.type = PCFSoftShadowMap ✓.**

**Tone mapping:** ACESFilmic, exposure 1.25.

**Check flame base overlapping ground:** cones at y 0.12 with additive — base slightly above ground, dirt patch under ✓.

**Sparks spawn radius 0.1 + life*...** starts inside flame ✓ rises to 5+ height with fade ✓. Some drift outward up to r ~1.3 ✓.

**Fireflies anchors radius 3-14** — some near benches, some among trees ✓ y 0.5-3.0 ✓.

Now, quantity check on stars vs fog far 80: stars unaffected ✓.

**Tent extrude UV/normals:** ExtrudeGeometry normals smooth on sides? flatShading fixes ✓.

Tent dimensions: width ±1.15, height 1.5, depth 2.6 → cozy 1-person tent, scale up: scale.setScalar(1.25)? height 1.9 fine. Actually make shape triangle (-1.3, 0) (1.3, 0) (0, 1.7), depth 3.0. Good size.

Opening: ShapeGeometry(same triangle scaled 0.8, positioned front z=depth/2+0.01, y0.02). ShapeGeometry triangle: Shape from points (-1.0,0.02) (1.0,0.02) (0,1.3). Mesh basic 0x07050a. Rotation: shape faces +z ✓ matches front at local +z (extrude from 0 to 3.0 then translate -1.5 → front at +1.5).

Hmm — extrude translate: geo.translate(0, 0, -1.5) centers; front face z=+1.5. Opening mesh at z=1.51.

Tent position (3.8, groundHeight(3.8,-6.8) + ~0? base at ground: tent geometry base y=0 → position.y = groundHeight - 0.05. rotation.y = atan2(-3.8, 6.8)?? face fire: front (+z local) toward fire: object +z should point from tent to fire: direction tent→fire = (0-3.8, 0-(-6.8)) = (-3.8, 6.8) in xz. rotation.y such that local +z maps to that dir: for rotation.y = θ, local +z (0,0,1) → (sin θ, 0, cos θ). Need sinθ = -3.8/7.79 = -0.488, cosθ = 6.8/7.79 = 0.873 → θ = -0.51 rad ✓ as computed.

**Also ground dip under tent:** groundHeight at (3.8,-6.8): flat factor = smoothstep(7.79, 2.5, 12) = smoothstep at d=7.79: (7.79-2.5)/9.5 = 0.557 → smooth ≈ 0.575? compute smoothstep(0.557): 3x²-2x³ = 3*0.310-2*0.173 = 0.930-0.346 = 0.584. h = (sin(3.8*0.16)=sin0.608=0.571 * cos(-6.8*0.19)=cos(-1.292)=0.275)*0.9 = 0.141... + second term sin(3.8*0.42+1.7)=sin(3.296)=-0.153... cos(-6.8*0.35+0.6)=cos(-1.78)= -0.213 → product 0.0326*0.4=0.013; third: sin(3.8*0.85+0.3)=sin(3.53)=-0.383; sin(-6.8*0.78+1.1)=sin(-4.204)= 0.874 → -0.335*0.18=-0.060. total ≈ 0.141+0.013-0.060 = 0.094 * 0.584 = 0.055. Tent base y ≈ 0 ✓ roughly.

Fine — placement code computes exact.

**Tent receives fire shadow?** castShadow ✓ receiveShadow ✓.

**Bench logs + firewood barkMat shared ✓.**

Let me also consider adding **charred fire pit circle**: dark circle mesh slightly above ground under fire: CircleGeometry(1.0, 9)? Would z-fight with ground displacement — ground near center is flat (d<2.5 → height 0 exactly since flat factor 0) ✓ plane there is exactly y=0. So a charred circle at y=0.012 radius 0.95, MeshStandardMaterial color 0x0d0906 roughness 1, polygonOffset? Small y offset 0.012 fine, no z-fight (16-bit? we have 24-bit depth near 0.1/1500... near 0.1 far 1500 — at distance 16, depth precision ~ (16²)*(2/(0.1*1500*(2^24)))?? plenty for 1.2cm). ✓ Actually make it slightly concave? Keep circle, receiveShadow true. Add ring of darker vertex color? The dirt vertex color patch already there; charred circle adds definition. Add: circle radius 0.85, color 0x120b06.

Hmm, dirt patch color lerp 0.9 to 0x3c2d1c at d<1.2 — charred circle covers center anyway.

**Grass blades:** ConeGeometry(0.06, 0.5, 4) translate(0,0.25,0); instanced 260; per instance: pos radius 2.6-16 (uniform sqrt), rot y random, tilt random ±0.25 rad around random axis (approx via rotation.x/z), scale 0.6-1.4. color: 0x2a3d20..0x3a4a28 lerp jitter. castShadow false, receiveShadow false. Actually tilt: dum.rotation.set((Math.random()-0.5)*0.5, Math.random()*6.28, (Math.random()-0.5)*0.5).

Grass blades at night mostly silhouette — fine texture.

**Fire glow lighting the grass** — instanced standard material ✓ lit.

**Code size:** manageable, ~600 lines. Let me write it now, being careful about every API detail.

Details to double check while writing:
1. `new THREE.Fog(color, near, far)` ✓.
2. `renderer.outputColorSpace` default SRGB ✓ no need to set.
3. `THREE.PCFSoftShadowMap` ✓.
4. PointLight(color, intensity, distance, decay) ✓.
5. `light.shadow.mapSize.set(1024, 1024)` ✓.
6. DirectionalLight shadow camera: `light.shadow.camera.left = -60` etc ✓.
7. Sprite: `new THREE.Sprite(new THREE.SpriteMaterial({map, ...}))`, scale.set(sx, sy, 1) ✓.
8. CanvasTexture: `new THREE.CanvasTexture(canvas)`; colorSpace: set `tex.colorSpace = THREE.SRGBColorSpace` for correct color ✓ (gradient authored in sRGB).
9. `geometry.setAttribute('aSeed', new THREE.BufferAttribute(arr, 1))` ✓.
10. InstancedMesh setColorAt then `im.instanceColor.needsUpdate = true` (setColorAt creates instanceColor) ✓.
11. Points frustumCulled false for shader-positioned (sparks positions computed in shader from origin-based coords; geometry positions all zeros → bounding sphere zero at fire — culling: camera looks at fire mostly ✓ but set frustumCulled false to be safe).
   - Stars: positions real → fine but set false anyway? Stars sphere 480 — bounding fine ✓ leave true (cheap). Actually make sparks geometry positions zero vector; must set frustumCulled=false ✓.
   - Fireflies: anchors in 'position' attribute → bounding ok ✓ leave.
12. `controls.update()` per frame ✓.
13. clock: `new THREE.Clock()` — starts on first getDelta ✓.
14. Remove loading after first render: flag.

Also I should ensure **no console errors**: 
- `THREE.MathUtils.smoothstep` exists ✓.
- ShaderMaterial `#include` chunks valid ✓.
- `renderer.setAnimationLoop` vs rAF — use setAnimationLoop (cleaner) — either fine; use rAF... setAnimationLoop is standard ✓ use it.

One more: attribute name "position" required by shaders; sparks geometry: setAttribute('position', zeros) ✓ used as base (we ignore but three needs it; vertex shader references position? My spark shader doesn't use `position` — but WebGL requires? Unused attributes fine; but three warns if geometry missing position? Points geometry: drawing count from position attribute — MUST have position to define count! set zeros Float32Array(count*3) ✓ count defined ✓.

In spark vertex shader I don't reference position → compiler drops attribute → fine.

Star shader references position ✓; fireflies ✓.

**Flame aRand attribute**: ConeGeometry vertices count = (7+1)*(4+1)+? whatever; fill random per vertex ✓.

**Let me also handle context loss?** skip.

**Autorotate direction & speed:** -0.5 (slow drift). After user interaction stops permanently (flag).

**Also hide hint after 12s automatically?** Keep visible until interaction — it's subtle. OK.

**Title text:** "Emberwood" / subtitle "a low-poly night in the forest". Hint: "drag to orbit · scroll to zoom · pinch on touch".

Also add tiny footer credit? no.

**Write final HTML now.**

Let me write the full code mentally ordered:

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Emberwood — a low-poly night camp</title>
<style>
  html, body { height: 100%; }
  body { margin: 0; overflow: hidden; background: #04060c; font-family: "Palatino Linotype", Georgia, "Times New Roman", serif; }
  canvas { display: block; }
  #vignette { position: fixed; inset: 0; pointer-events: none; z-index: 3;
    background: radial-gradient(130% 105% at 50% 40%, rgba(0,0,0,0) 46%, rgba(2,3,9,.62) 100%); }
  .brand { position: fixed; top: 26px; left: 30px; z-index: 4; pointer-events: none; display: flex; gap: 12px; align-items: center; }
  .brand svg { filter: drop-shadow(0 0 10px rgba(255,150,60,.6)); }
  .brand h1 { margin: 0; font-size: 15px; font-weight: 400; letter-spacing: .5em; text-transform: uppercase; color: #ecd9b0; text-shadow: 0 0 26px rgba(255,150,60,.35); }
  .brand p { margin: 6px 0 0; font-size: 9px; letter-spacing: .32em; text-transform: uppercase; color: rgba(214,196,158,.5); }
  #hint { position: fixed; z-index: 4; left: 50%; bottom: 28px; transform: translateX(-50%); pointer-events: none;
    font-size: 10px; letter-spacing: .3em; text-transform: uppercase; color: rgba(235,218,182,.8); white-space: nowrap;
    padding: 11px 20px 11px 23px; border: 1px solid rgba(235,200,150,.16); border-radius: 999px;
    background: rgba(6,8,14,.5); backdrop-filter: blur(6px); transition: opacity .9s ease; }
  #hint.hide { opacity: 0; }
  #loading { position: fixed; inset: 0; z-index: 10; display: flex; align-items: center; justify-content: center;
    background: #04060c; color: rgba(226,206,164,.75); font-size: 11px; letter-spacing: .4em; text-transform: uppercase;
    transition: opacity .7s ease; }
  @media (max-width: 560px){ .brand { top: 18px; left: 18px; } #hint { bottom: 18px; font-size: 9px; } }
</style>
</head>
<body>
<div id="vignette"></div>
<div class="brand">
  <svg width="13" height="17" viewBox="0 0 14 18" aria-hidden="true">
    <defs><linearGradient id="fg" x1="7" y1="18" x2="7" y2="0" gradientUnits="userSpaceOnUse">
      <stop stop-color="#ff7a1a"/><stop offset="1" stop-color="#ffd873"/></linearGradient></defs>
    <path d="M7 0c1.2 3.6 5 5.4 5 9.7C12 13.4 9.9 16 7 16s-5-2.6-5-6.3C2 7.5 3 6 4.6 4.4c.2 2 .9 2.9 1.9 3.3C6 5.2 6.4 2.4 7 0Z" transform="translate(0 1)" fill="url(#fg)"/>
  </svg>
  <div><h1>Emberwood</h1><p>low-poly camp · night clearing</p></div>
</div>
<div id="hint">drag to orbit · scroll to zoom</div>
<div id="loading">stoking the fire…</div>

<script type="importmap">
{ "imports": { "three": "...", "three/addons/": "..." } }
</script>
<script type="module">
... everything ...
</script>
</body>
</html>
```

Now the module. Writing carefully:

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

/* ---------- basics ---------- */
const scene = new THREE.Scene();
const FOG_COLOR = new THREE.Color(0x0d1526);
scene.fog = new THREE.Fog(FOG_COLOR, 20, 82);

const camera = new THREE.PerspectiveCamera(55, innerWidth / innerHeight, 0.1, 1600);
camera.position.set(8.5, 3.0, 14.0);

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

Wait — canvas appended after overlay divs: z-order: canvas default position static in body flow... overlay divs are position fixed → above ✓.

```js
/* ---------- helpers ---------- */
const rand = (a, b) => a + Math.random() * (b - a);
const smooth = THREE.MathUtils.smoothstep;

function groundHeight(x, z){
  const d = Math.hypot(x, z);
  const flat = smooth(d, 2.5, 12);
  const h = Math.sin(x * 0.16) * Math.cos(z * 0.19) * 0.9
          + Math.sin(x * 0.42 + 1.7) * Math.cos(z * 0.35 + 0.6) * 0.42
          + Math.sin(x * 0.85 + 0.3) * Math.sin(z * 0.78 + 1.1) * 0.18;
  return h * flat;
}
```

Lights:

```js
const moonDir = new THREE.Vector3(-0.35, 0.30, -0.89).normalize();

const hemi = new THREE.HemisphereLight(0x35507a, 0x181310, 0.55);
scene.add(hemi);

const moonLight = new THREE.DirectionalLight(0x9db8e8, 0.55);
moonLight.position.copy(moonDir).multiplyScalar(90);
moonLight.castShadow = true;
moonLight.shadow.mapSize.set(2048, 2048);
moonLight.shadow.camera.near = 20;
moonLight.shadow.camera.far = 220;
const S = 62;
moonLight.shadow.camera.left = -S; right = S; top = S; bottom = -S;
moonLight.shadow.bias = -0.0004;
moonLight.shadow.normalBias = 0.3;
scene.add(moonLight);
scene.add(moonLight.target);
```

Hmm normalBias 0.3 might be too much for pointlight artifacts... it's only for this light. Trees' shadow offset by 0.3 units — negligible visually at these light levels. Actually normalBias 0.3 with texel ~0.06 — normalBias in "world units along normal" — 0.3 world units is large-ish; typical value 0.02-0.05 for such maps... but low-poly big triangles suffer acne; peter-panning 0.3 barely visible for trees. Use 0.15.

Fire light:

```js
const fireLight = new THREE.PointLight(0xff8c3a, 230, 60, 2);
fireLight.position.set(0, 1.25, 0);
fireLight.castShadow = true;
fireLight.shadow.mapSize.set(1024, 1024);
fireLight.shadow.camera.near = 0.08;
fireLight.shadow.camera.far = 60;
fireLight.shadow.bias = -0.006;
fireLight.shadow.normalBias = 0.04;
scene.add(fireLight);
```

Sky:

```js
const sky = new THREE.Mesh(
  new THREE.SphereGeometry(700, 40, 24),
  new THREE.ShaderMaterial({...})
);
```
Sphere radius 700 & camera far 1600 ✓. 40×24 segs fine.

Stars at 600, moon 585 — update distances: inside sky 700 ✓.

Moon:

```js
const moonPos = moonDir.clone().multiplyScalar(585);
const moon = new THREE.Mesh(new THREE.IcosahedronGeometry(16, 1), new THREE.MeshBasicMaterial({ color: 0xdce8f8, fog: false }));
moon.position.copy(moonPos);
scene.add(moon);
// halo sprite
const haloTex = makeRadialTex([[0,'rgba(215,228,255,0.55)'],[0.25,'rgba(160,185,240,0.18)'],[0.55,'rgba(120,150,220,0.05)'],[1,'rgba(0,0,0,0)']]);
const halo = new THREE.Sprite(new THREE.SpriteMaterial({ map: haloTex, blending: THREE.AdditiveBlending, depthWrite: false, transparent: true, fog: false }));
halo.position.copy(moonDir).multiplyScalar(580);
halo.scale.set(150, 150, 1);
scene.add(halo);
```

Moon angular radius: atan(16/585) ≈ 1.57° → diameter ~3.1° nice. Halo 150/580 → ~7.4° radius soft glow ✓.

makeRadialTex:

```js
function makeRadialTex(stops, size = 256){
  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 [o, c] of stops) g.addColorStop(o, c);
  ctx.fillStyle = g; ctx.fillRect(0, 0, size, size);
  const tex = new THREE.CanvasTexture(cv);
  tex.colorSpace = THREE.SRGBColorSpace;
  return tex;
}
```

Stars:

```js
const starCount = 1100;
positions/colors/sizes/phases/tw
for (...){
  const y = Math.pow(Math.random(), 0.9) * 0.98 + 0.02;  // slight horizon bias
  hmm uniform: y = 0.02 + Math.random()*0.96;
  const rr = Math.sqrt(Math.max(0, 1 - y*y));
  const th = Math.random() * Math.PI * 2;
  pos = [Math.cos(th)*rr*600, y*600, Math.sin(th)*rr*600];
  const c = pick color: r=Math.random(): <0.72 white (1,1,1)*b, <0.86 warm, else blue; brightness 0.5+0.5*Math.random() with pow bias.
  size = 1.2 + Math.pow(Math.random(), 3.5) * 3.2; bright few.
  phase random*2π; tw 0.4+Math.random()*1.8;
}
```
Star y min 0.02: near-horizon stars behind trees occluded ✓ some visible through fog band — nice.

Fireflies:

```js
const flyCount = 26;
anchors: radius 3 + sqrt(rand)*11 (3..14), y 0.6+rand*2.4; aSeed vec4 rand.
```

Sparks:

```js
const sparkCount = 140; positions zero; aSeed, aSpeed (0.22+rand*0.35), aAngle rand*2π.
```

Flames: as planned, flameGroup at y 0.14, four cone meshes. aRand per-vertex.

Materials with shared uniform object? Simpler each has own uniforms; keep array flameMats.

Charred pit circle.

Ground, rocks, logs, benches, firewood, tent, teepee, embers, grass, trees.

Teepee uses lookAt AFTER adding to group at final position: careful — lookAt uses world coordinates; fire group at origin so local == world ✓ (group not transformed).

Embers: geometry DodecahedronGeometry(1,0) scaled per mesh via mesh.scale — shared geometry with per-mesh scale ✓; material shared emberMat for pulse ✓.

Smoke sprites.

Now — I realize the ember emissiveIntensity with MeshStandardMaterial: emissive color 0xff5a1e * intensity. At intensity 1.7+fl*0.9 (~0.8-2.6) ✓ nice pulse.

**Tent z-fight:** opening at 1.51 vs tent face 1.5 ✓ 0.01 gap at distance ~18: depth precision fine ✓.

**Extrude UV fine.**

**Firewood pile:**

```js
const pile = new THREE.Group();
const stickGeo = new THREE.CylinderGeometry(0.07, 0.09, 1.2, 6);
stickGeo.rotateZ(Math.PI/2); // along x
positions: 
 row: z offsets [-0.11, 0, 0.11]? but logs along x, stack across z:
 l1: (0, 0.075, -0.12), (0, 0.075, 0.02), (0, 0.075, 0.16)?? make 3 bottom: z -0.12, 0.02, 0.16 hmm uneven; use -0.12, 0, 0.12
 top 2: (0.06, 0.2, -0.06), (-0.05, 0.2, 0.06)
pile.position.set(2.35, groundHeight(2.35, -3.5), -3.5);
pile.rotation.y = rand(0, Math.PI);
each mesh castShadow true, receiveShadow true.
```
Cylinder rotated: rotateZ(π/2) then length along x ✓ centered ✓ y = radius for ground contact (0.075).

**Fallen logs:**

```js
for (let i = 0; i < 3; i++){
  const len = rand(2.2, 3.6), rad = rand(0.18, 0.3);
  const geo = new THREE.CylinderGeometry(rad * 0.9, rad, len, 7);
  const m = new THREE.Mesh(geo, barkMat);
  const a = rand(0, Math.PI * 2), r = rand(5, 10.5);
  const x = Math.cos(a) * r, z = Math.sin(a) * r;
  m.position.set(x, groundHeight(x, z) + rad * 0.7, z);
  m.rotation.set(0, rand(0, Math.PI * 2), Math.PI / 2);
  m.rotation.order?? rotation applied XYZ: with z=π/2 first? Euler order XYZ applies... Euler XYZ intrinsic: R = Rx*Ry*Rz? three applies rotation matrix from Euler with order 'XYZ' meaning R = Rx·Ry·Rz? It composes as extrinsic? Honestly: set rotation.y then rotation.z=π/2 — result: cylinder lies horizontal with yaw ✓ (Rz tips it over in its local frame after yaw...). With order XYZ: matrix = Rx * Ry * Rz. Cylinder axis Y; Rz(π/2) tips axis to X (local), then Ry rotates around vertical → axis horizontal rotated ✓. So rotation.set(rand(-0.06, 0.06), yaw, π/2) works with default order. ✓
  m.castShadow = m.receiveShadow = true;
}
```

**Benches:**

```js
const benchDefs = [ { a: 3.6, r: 2.7 }, { a: 0.9, r: 2.9 } ]; // radians
for each: geo cylinder(0.19, 0.22, 2.4, 7); position (cos a * r, groundHeight+0.18, sin a * r); rotation.set(0, a + Math.PI/2 + rand(-0.2,0.2), Math.PI/2);
```
Wait rotation.y = a + π/2 makes log tangential ✓.

**Ring stones:** 10 rocks size rand(0.16, 0.26) at r 1.02-1.22 jitter angle; y = 0.03 (ground 0); makeRock with jitter; castShadow ✓.

**Scattered rocks:** 9 size 0.22-0.6 radius 3-11.5; **boulders:** 3 size 0.9-1.5 at radius 10.5-15.

Rock y placement: sink = size*0.35: y = groundHeight(x,z) + size*0.1? Let me: rocks geometry roughly spherical radius size (after jitter ±0.28size) → center y = groundY + size*0.45.

Colors: gray palette: 0x4b5158, 0x555b60, 0x3f464e, 0x5a6066 slight random lerp toward 0x6a6f73.

Rock material per rock (few, fine).

**Grass:** 

```js
const grassGeo = new THREE.ConeGeometry(0.06, 0.55, 4);
grassGeo.translate(0, 0.27, 0);
const grassMat = new THREE.MeshStandardMaterial({ color: 0xffffff, flatShading: true, roughness: 1 });
const grass = new THREE.InstancedMesh(grassGeo, grassMat, 240);
```
per instance color; positions radius 2.7-16: `const rr = 2.7 + Math.sqrt(Math.random()) * 13.3;` y = groundHeight - 0.02, scale s rand(0.5, 1.5) with x/z maybe 1; rotation tilt.

grass.receiveShadow? false. castShadow false.

**Trees instanced build** — as planned. Let me allocate counts after generating spots: pines array & decid array.

```js
const pineSpots = [], leafSpots = [];
while (pineSpots.length + leafSpots.length < 52) { ... pick type by rand<0.7 → pine else leaf; check min dist 2.7; push {x, z, s: rand(0.8, 1.5), hj: rand(0.9, 1.2), rot: rand(0, 6.28)} }
```

Then:

```js
const pineTrunkGeo = new THREE.CylinderGeometry(0.13, 0.22, 1.7, 5); pineTrunkGeo.translate(0, 0.85, 0);
const pineConeGeos = [ [1.35, 2.0, 0.45], [1.0, 1.7, 1.45], [0.62, 1.4, 2.4] ].map(([r, h, y]) => { const g = new THREE.ConeGeometry(r, h, 7); g.translate(0, y + h/2? no—
```
Hold on: I want each cone's base at level y: geometry cone centered → translate(0, y + h/2, 0). Then instance local matrix identity! Because translation baked into geometry per level ✓ simpler: no per-level local matrix needed — three separate InstancedMesh each with geometry pre-translated. Then setMatrixAt(i, treeMatrix) same for all levels. 

But per-tree scale: treeMatrix scale (s, s*hj, s) applies uniformly to pre-translated geometry ✓ scales positions too ✓ correct.

Pine cone overlap: L1 base 0.45 h 2.0 → top 2.45; L2 base 1.45 h 1.7 → top 3.15; L3 base 2.4 h 1.4 → top 3.8. Trunk h 1.7 top 1.7 — hidden inside L2 ✓. Widths: 1.35, 1.0, 0.62 ✓ nice stepped pine.

Deciduous:

```js
dTrunkGeo = CylinderGeometry(0.15, 0.24, 2.3, 5); translate(0,1.15,0);
blobGeo = IcosahedronGeometry(1.2, 0);
blob levels baked can't bake different offsets into one geometry — use local matrices: blobIM count = 3*nLeaf; local matrices: [ (0, 2.7, 0) scale 1, (0.75, 2.15, 0.4) scale 0.6, (-0.6, 2.35, -0.35) scale 0.55 ]
```
Compose: final = treeM * localM where localM = compose(pos, identity q, scale). Since treeM has rotation & scale, multiplication order: treeM.multiply(localM) → applies local first ✓ correct (scale then rotate).

For pine cones geometry pre-translated & identity local ✓.

Wait — geometry translate baked then instance scale (s, s*hj, s): vertical translation y gets scaled by s*hj ✓ consistent with trunk (also scaled) ✓.

Colors: per tree single color for foliage: cBase = Color(pick). Then per level multiplyScalar(0.85 + 0.1*k)? setColorAt per level instance: level color = cBase.clone().multiplyScalar(0.82 + k*0.13). Trunk color separate brown.

InstancedMesh materials: MeshStandardMaterial({color 0xffffff, flatShading true, roughness 0.95}) — instanceColor multiplies ✓.

Leaf blob color slightly varied per blob.

**Deciduous blob jitter:** blobs identical icosahedron shape rotated differently per instance — bake rotation in local matrices (random quat per tree? per blob local rotation random): localM = compose(pos, randomQuat, scale) ✓ varied silhouettes.

**Now the flame shader attributes:** aRand:

```js
function addRand(geo){ const n = geo.attributes.position.count; const arr = new Float32Array(n); for (let i=0;i<n;i++) arr[i]=Math.random(); geo.setAttribute('aRand', new THREE.BufferAttribute(arr,1)); }
```

Flame creation:

```js
const flameGroup = new THREE.Group(); flameGroup.position.y = 0.14; scene.add(flameGroup);
const flameMats = [], flameMeshes = [];
function addFlame(r, h, phase, intensity, colA, colB){
  const geo = new THREE.ConeGeometry(r, h, 7, 4);
  geo.translate(0, h/2, 0);
  addRand(geo);
  const mat = new THREE.ShaderMaterial({ ... uniforms uTime 0, uPhase phase, uHeight h, uIntensity intensity, uColA Color(colA), uColB Color(colB) ... });
  const mesh = new THREE.Mesh(geo, mat);
  mesh.userData.phase = phase;
  flameGroup.add(mesh); flameMats.push(mat); flameMeshes.push(mesh);
}
addFlame(0.75, 0.6, 1.3, 1.1, 0xffa040, 0xd43000);   // wide base
addFlame(0.55, 1.75, 0.0, 1.6, 0xffc266, 0xff4a00);  // main
addFlame(0.38, 1.3, 2.4, 1.8, 0xffd28a, 0xff5a0a);   // secondary
addFlame(0.22, 0.95, 4.6, 2.4, 0xfff4c8, 0xffa020);  // hot core
```

Scale anim: mesh.scale.set(1 - 0.05*sin, 1 + 0.07*sin, 1 - 0.05*sin2...).

Also whole flameGroup slight lean? skip.

**Also, ensure flames don't cast/receive shadows** — default false ✓ ShaderMaterial doesn't support anyway.

**Glow sprite tex:**

```js
const glowTex = makeRadialTex([[0,'rgba(255,190,110,0.85)'],[0.22,'rgba(255,140,50,0.35)'],[0.5,'rgba(255,100,30,0.1)'],[1,'rgba(255,80,20,0)']]);
const glow = new THREE.Sprite(new THREE.SpriteMaterial({ map: glowTex, blending: THREE.AdditiveBlending, depthWrite: false, transparent: true, fog: false }));
glow.position.set(0, 1.05, 0);
glow.scale.set(5.6, 5.6, 1);
```
SpriteMaterial opacity animated ✓.

**Smoke tex:**

```js
const smokeTex = makeRadialTex([[0,'rgba(190,180,170,0.5)'],[0.6,'rgba(150,140,135,0.16)'],[1,'rgba(120,115,110,0)']]);
smoke sprites color 0xffffff (texture carries tone), NormalBlending, depthWrite false, transparent, fog false, rotation random per sprite? Sprite material rotation property per material — clones each: mat.rotation = rand(0, 6.28) ✓ and rotate over time? s.material.rotation += dt*0.2 — nice.
```

Smoke sprite scale up to ~4.5 world units at height 5.9 ✓.

**Star material** — done above.

**uScale uniform shared:** create `const pointScale = { value: 1 }` object reused in both materials (shared reference — assign same object to both uniforms uScale: pointScale) ✓ update one place.

uPixelRatio for stars: shared object too.

**Resize function:**

```js
function onResize(){
  const w = innerWidth, h = innerHeight;
  camera.aspect = w / h; camera.updateProjectionMatrix();
  renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
  renderer.setSize(w, h);
  const pr = renderer.getPixelRatio();
  pointScale.value = (h * pr) / (2 * Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2));
  prUniform.value = pr;
}
addEventListener('resize', onResize);
onResize();
```

**Sky material fog false, tonemapped ✓.**

**Let me now also double-check the flame vertex shader h computation:** uHeight = cone height (e.g., 1.75) ✓ p.y ranges 0..h after translate ✓.

**One concern: flame cones with 4 heightSegments and 7 radial → vertices along shared seam duplicated: aRand differs per duplicate → seam crack in additive? Additive blending — a seam shows as thin darker line where offsets differ (both faces still render, no holes since triangles remain watertight? Displaced differently → crack gap possible visually (background shows through). It's fire — noisy — acceptable, even adds flicker. OK.

Alternatively derive rand from position in shader: `float aRand = fract(sin(dot(position.xy, vec2(12.9898,78.233))) * 43758.5453);` → seam-safe (same position → same value)! Better — drop attribute entirely. Use that hash in shader. But then wobble same for same height ring? The hash uses position.xy — varies along x too ✓ fine. And deterministic per vertex position ✓ no cracks. But animation "noise" per vertex static offset in time — fine.

I'll use hash-of-position, drop aRand attribute. Also flame side-to-side sway uses uPhase per flame ✓ variety.

**Fireflies fragment:**

```glsl
vec2 uv = gl_PointCoord - 0.5; float d = length(uv)*2.0;
float a = smoothstep(1.0, 0.1, d) * vA * 0.9;
gl_FragColor = vec4(vec3(0.62, 0.9, 0.32) * 1.4, a);
```

with colorspace include.

**Star fragment** as above.

**Check: does `#include <colorspace_fragment>` compile in ShaderMaterial fragment? The chunk references gl_FragColor ✓ and linearToOutputTexel defined in prefix ✓. Yes this pattern is widely used (three docs recommend). ✓

**Controls 'start' listener + hint:**

```js
const hintEl = document.getElementById('hint');
let interacted = false;
controls.addEventListener('start', () => {
  if (!interacted){ interacted = true; controls.autoRotate = false; hintEl.classList.add('hide'); }
});
```

**Loading removal:**

```js
const loadingEl = document.getElementById('loading');
function firstFrame(){ ... } — call inside animate once:
if (!ready){ ready = true; loadingEl.style.opacity = '0'; setTimeout(() => loadingEl.remove(), 800); }
```

**Shadow on ground from moon through trees:** ✓.

**Check hemisphere ground color param order:** HemisphereLight(skyColor, groundColor, intensity) ✓.

**scene.background:** set to new Color(0x04060c) fallback (behind sky mesh; never visible) fine.

**Potential perf on point light shadow cube + 2048 dir map:** OK.

**Instanced + point light shadow:** works ✓.

Also `renderer.shadowMap.autoUpdate` — light flickers position slightly → shadows update each frame (needed anyway since flame anim? shadows from static geometry but light position jitters → keep auto update default true).

**Now compose the full file.** Also count trees: ensure ≥30 even if rejection sampling struggles: annulus 18-50, min dist 2.7 — area huge, 52 fine.

Wait, one thing: tree min radius 18 but requirement "placed randomly around the clearing" ✓, and camera maxDistance 21 → orbiting at 21 could clip inner trees (18-21). Compromise: maxDistance 20, trees ≥ 19? Then screenshot camera at 16.5 ✓ and orbit within 20 vs trees ≥19 — 1 unit overlap only at extreme zoom-out + specific azimuth. Acceptable. Actually make it cleaner: trees ≥ 20, maxDistance 19. Screenshot distance 16.5 ✓ fine. Fog far 82 — trees 20-50 ✓ good depth. Let me set: tree r = 20 + sqrt(rand)*30 (20..50), maxDistance 19, minDistance 5.

Check initial camera dist 16.5 < 19 ✓.

But now with maxDistance 19 the user can't zoom way out — fine, keeps composition tight.

**Star count 1100 — with additive small points fine.**

Also **moon reflection in nothing** — n/a.

Let me also reconsider flame bottom: cones base radius 0.75 at y 0.14 — base circle edge might look detached from logs; the wide base flame r 0.75 covers ✓.

**Embers placement radius <0.55 around center, y ~0.06-0.1, scale 0.1-0.2 (DodecahedronGeometry(1,0) scaled)** ✓.

Also **small stones glow?** no.

**Charred ground circle:** CircleGeometry(1.05, 10) rotated -π/2, y 0.015, color 0x100a06, roughness 1, receiveShadow true. Under fire ✓.

Also maybe **ash ring lighter gray** skip.

**Verify tent doesn't intersect trees:** tent at (3.8, -6.8) r 7.79 < 20 ✓ clear.

Firewood (2.35, -3.5) r 4.2 ✓.

**Now, potential issue — teepee logs lookAt:** fireGroup children positions local; lookAt world — fireGroup at (0,0,0) not transformed ✓ world == local ✓.

Alright — also confirm `m.lookAt(x,y,z)` accepts numbers ✓.

**Ember + charred + flames + light all inside fire area ✓.**

**AutoRotate + damping:** controls.update() every frame ✓.

**Star twinkle update ✓.**

Let me also add **camera subtle idle bob?** No — OrbitControls owns camera.

**Write it all out now.** I'll aim for clean, commented, organized code.

Additional check — `THREE.MathUtils.smoothstep(d, 2.5, 12)` — for d beyond 12 returns 1 ✓; below 2.5 → 0 ✓.

Grass region 2.7-16 partly under trees (trees at ≥20) ✓ no clash.

Bench log at r 2.7-2.9 — grass starts 2.7 ✓ may poke through bench; visually fine (grass tufts small).

Rock ring r ~1.1 ✓.

One more potential visual: **fog near 20** — camera at 16.5 → scene center no fog ✓; trees 20-50 fogged 0-47%+ ✓.

**Also the ground beyond 82 fully fog colored** — ground radius 75 (150 plane) → corners 106 ✓ all beyond fog far within ground? At 82+ everything fog color — but sky visible above treeline at those distances... where ground ends at 75-106 units, sky takes over; horizon line where fogged ground meets sky: both fog color ✓ seamless.

Trees max 50 ✓ within ground ✓.

**Boulder at up to 15** ✓.

Double-check hemisphere light color choices — 0x35507a sky-ish blue, ground 0x181310 warm-dark, intensity 0.55. Plus dir 0.55. Combined night ambience: ground albedo dark greens ~0.15 → visible dim ✓.

Exposure 1.25 ACES ✓.

Fire light 230 intensity at 2m = 57 → ACES compresses → bright ✓ at 5m: 9.2 warm ✓ at 10m: 2.3 ✓ at 20m: 0.57 subtle ✓ distance cutoff 60 ✓.

**Sparks visual size:** aSize (0.045..0.075) * (1 - 0.75*life) at 16m: 0.045*768/16 ≈ 2.2px ✓ nice.

**Fireflies size:** (0.05+0.04*blink)*768/12 ≈ 3-5px ✓.

uScale = h*pr/(2 tan(27.5°)) = 800/(2*0.5206) = 768 at pr 1 ✓.

**Star gl_PointSize:** aSize * pr, sizes 1.2-4.4 → ✓.

**Colors as attributes:** star aColor — I'll write linear values from THREE.Color(hex).r etc ✓.

Now writing the final code — I'll be meticulous with syntax.

Actually one more thought about `#include <colorspace_fragment>` in the flame/star/spark/firefly shaders with AdditiveBlending: conversion is per-fragment on the additive source — mathematically "wrong" (conversion should be after summation) but visually fine and standard practice in three examples? Many particle demos do include it. Alternatively omit → colors treated as already-sRGB → slightly darker mids. I'll include it — brighter, more vivid fire. ✓

Also, note: `varying` + GLSL1 default — ShaderMaterial default glslVersion null → GLSL1 with WebGL2 wrapper (three converts). ✓

**Firefly shader alpha:** many zero → additive invisible ✓.

**Ensure spark loop `fract` never stalls:** aSpeed 0.22-0.57 loops 0.22-0.57/sec — sparks recur ~2-4s each, 140 particles staggered → continuous stream ✓.

Height: life*(2.2 + aSeed*3.0) → max ~5.2 ✓.

**Smoke drift x:** seed-based ✓.

**Ember mat:** `new THREE.MeshStandardMaterial({ color: 0x1c0d06, emissive: 0xff5a1e, emissiveIntensity: 2, flatShading: true, roughness: 0.8 })`.

**Now, final answer assembly.** Also remember: no horizontal scrollbar (overflow hidden ✓), responsive ✓, first paint: loading screen visible immediately (CSS) ✓ then scene ~immediately.

Title/hint font: using serif Georgia for elegance — with letterspacing. Fine cross-platform.

Let me write the whole thing:

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Emberwood · a low-poly night camp</title>
<style>
  html, body { height: 100%; }
  body { margin: 0; overflow: hidden; background: #04060c; font-family: Georgia, "Times New Roman", serif; -webkit-font-smoothing: antialiased; }
  canvas { display: block; }
  #vignette { position: fixed; inset: 0; z-index: 3; pointer-events: none;
    background: radial-gradient(135% 105% at 50% 42%, rgba(0,0,0,0) 48%, rgba(2,3,9,.6) 100%); }
  .brand { position: fixed; top: 26px; left: 30px; z-index: 4; pointer-events: none; display: flex; align-items: flex-start; gap: 13px; }
  .brand svg { margin-top: 2px; filter: drop-shadow(0 0 9px rgba(255,150,60,.55)); }
  .brand h1 { margin: 0; font-size: 16px; font-weight: 400; letter-spacing: .48em; text-transform: uppercase; color: #ecd9b0; text-shadow: 0 0 26px rgba(255,150,60,.35); }
  .brand p { margin: 7px 0 0; font-family: Verdana, Arial, sans-serif; font-size: 8.5px; letter-spacing: .34em; text-transform: uppercase; color: rgba(214,196,158,.52); }
  #hint { position: fixed; z-index: 4; left: 50%; bottom: 28px; transform: translateX(-50%); pointer-events: none;
    font-family: Verdana, Arial, sans-serif; font-size: 10px; letter-spacing: .3em; text-transform: uppercase;
    color: rgba(235,218,182,.82); white-space: nowrap; padding: 12px 22px 12px 25px;
    border: 1px solid rgba(235,200,150,.15); border-radius: 999px; background: rgba(6,8,14,.5);
    backdrop-filter: blur(6px); transition: opacity .9s ease; }
  #hint.hide { opacity: 0; }
  #loading { position: fixed; inset: 0; z-index: 10; display: flex; align-items: center; justify-content: center;
    background: #04060c; color: rgba(226,206,164,.8); font-family: Verdana, Arial, sans-serif;
    font-size: 10.5px; letter-spacing: .42em; text-transform: uppercase; transition: opacity .7s ease; }
  @media (max-width: 600px){
    .brand { top: 18px; left: 20px; }
    .brand h1 { font-size: 14px; }
    #hint { bottom: 18px; font-size: 8.5px; padding: 10px 16px 10px 19px; }
  }
</style>
</head>
<body>
<div id="vignette"></div>
<div class="brand">
  <svg width="14" height="18" viewBox="0 0 14 18" aria-hidden="true">
    <defs><linearGradient id="fg" x1="7" y1="18" x2="7" y2="0" gradientUnits="userSpaceOnUse">
      <stop stop-color="#ff7a1a"/><stop offset="1" stop-color="#ffd873"/>
    </linearGradient></defs>
    <path d="M7 .8C8.2 4.3 12 6.1 12 10.3 12 14 9.9 16.6 7 16.6S2 14 2 10.3c0-2.1 1-3.6 2.6-5.2.2 2 .9 2.9 1.9 3.3C6 5.9 6.4 3.2 7 .8Z" fill="url(#fg)"/>
  </svg>
  <div>
    <h1>Emberwood</h1>
    <p>low-poly camp · night clearing</p>
  </div>
</div>
<div id="hint">drag to orbit · scroll to zoom</div>
<div id="loading">stoking the fire…</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';
...
</script>
</body>
</html>
```

Now the module content in full. Let me write it piece by piece with care.

```js
/* ============ renderer / scene / camera ============ */
const scene = new THREE.Scene();
const FOG_COLOR = new THREE.Color(0x0d1526);
scene.fog = new THREE.Fog(FOG_COLOR, 20, 82);
scene.background = new THREE.Color(0x04060c);

const camera = new THREE.PerspectiveCamera(55, innerWidth / innerHeight, 0.1, 1600);
camera.position.set(8.5, 3.0, 14.0);

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

const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1.1, 0);
controls.enableDamping = true;
controls.dampingFactor = 0.06;
controls.enablePan = false;
controls.minDistance = 5;
controls.maxDistance = 19;
controls.minPolarAngle = 0.15;
controls.maxPolarAngle = Math.PI / 2 - 0.07;
controls.autoRotate = true;
controls.autoRotateSpeed = -0.45;
controls.update();
```

Note controls.update() before first render to sync.

Helpers:

```js
const rand = (a, b) => a + Math.random() * (b - a);
const groundHeight = (x, z) => {
  const d = Math.hypot(x, z);
  const flat = THREE.MathUtils.smoothstep(d, 2.5, 12);
  const h = Math.sin(x * 0.16) * Math.cos(z * 0.19) * 0.9
          + Math.sin(x * 0.42 + 1.7) * Math.cos(z * 0.35 + 0.6) * 0.42
          + Math.sin(x * 0.85 + 0.3) * Math.sin(z * 0.78 + 1.1) * 0.18;
  return h * flat;
};
function radialTex(stops, size = 256) { ... }
```

Lights (as above).

Sky, moon, halo, stars, fireflies, sparks, ground, rocks, logs, tent, trees, grass, fire (charred circle, ring stones, teepee, embers, flames, glow), smoke, animation, resize.

Let me make sure everything referenced is defined in order (JS module top-level — functions hoisted if declared function; const not hoisted — order matters for execution but definitions used inside animate run later ✓).

Careful: `radialTex` used by glow/halo/smoke — define before.

Star geometry build:

```js
const starGeo = new THREE.BufferGeometry();
{
  const N = 1100;
  const pos = new Float32Array(N * 3), col = new Float32Array(N * 3);
  const size = new Float32Array(N), phase = new Float32Array(N), tw = new Float32Array(N);
  const c = new THREE.Color();
  for (let i = 0; i < N; i++){
    const y = 0.03 + Math.random() * 0.96;
    const rr = Math.sqrt(Math.max(0, 1 - y * y));
    const a = Math.random() * Math.PI * 2;
    pos[i*3] = Math.cos(a) * rr * 600; pos[i*3+1] = y * 600; pos[i*3+2] = Math.sin(a) * rr * 600;
    const t = Math.random();
    if (t < 0.7) c.setHSL(0.62, rand(-0.05, 0.1), rand(0.75, 1.0)); // hmm
```
Simpler: pick from tints:
```js
    const tint = Math.random();
    if (tint < 0.72) c.set(0xdfe8ff);
    else if (tint < 0.87) c.set(0xffe9c0);
    else c.set(0xaec8ff);
    c.multiplyScalar(rand(0.45, 1.0));
    col[i*3]=c.r; col[i*3+1]=c.g; col[i*3+2]=c.b;
    size[i] = 1.1 + Math.pow(Math.random(), 3.0) * 3.4;
    phase[i] = Math.random() * Math.PI * 2;
    tw[i] = rand(0.3, 2.2);
  }
  starGeo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
  starGeo.setAttribute('aColor', new THREE.BufferAttribute(col, 3));
  starGeo.setAttribute('aSize', new THREE.BufferAttribute(size, 1));
  starGeo.setAttribute('aPhase', new THREE.BufferAttribute(phase, 1));
  starGeo.setAttribute('aTw', new THREE.BufferAttribute(tw, 1));
}
const starUniforms = { uTime: { value: 0 }, uPR: { value: renderer.getPixelRatio() } };
const starMat = new THREE.ShaderMaterial({...});
const stars = new THREE.Points(starGeo, starMat);
scene.add(stars);
```

Shared uniform objects: define `const uTimeShared?` — different materials; just assign uniform objects: sparkMat.uniforms.uTime etc, update each in tick. Fine.

Actually I can share the SAME uniform value objects across materials:

```js
const uniTime = { value: 0 };
const uniScale = { value: 1 };   // for perspective point size
const uniPR = { value: renderer.getPixelRatio() };
```
Then in materials: `uniforms: { uTime: uniTime, uScale: uniScale }` — shared reference ✓ single update. 

Flames need per-material uTime — but they can share uniTime too and have their own uPhase etc ✓.

Fireflies: uniforms { uTime: uniTime, uScale: uniScale } ✓.
Sparks: same ✓.
Stars: { uTime: uniTime, uPR: uniPR } ✓.

Tick: uniTime.value = t ✓ one line.

**Moon:**

```js
const moonDir = new THREE.Vector3(-0.35, 0.3, -0.89).normalize();
const moon = new THREE.Mesh(new THREE.IcosahedronGeometry(16, 1), new THREE.MeshBasicMaterial({ color: 0xd7e4f6, fog: false }));
moon.position.copy(moonDir).multiplyScalar(585);
scene.add(moon);
const halo = new THREE.Sprite(new THREE.SpriteMaterial({
  map: radialTex([[0, 'rgba(210,225,255,0.5)'], [0.25, 'rgba(160,185,240,0.16)'], [0.55, 'rgba(120,150,220,0.05)'], [1, 'rgba(80,110,190,0)']]),
  blending: THREE.AdditiveBlending, depthWrite: false, transparent: true, fog: false
}));
halo.position.copy(moonDir).multiplyScalar(580);
halo.scale.set(150, 150, 1);
scene.add(halo);
```

**Sky:**

```js
const skyMat = new THREE.ShaderMaterial({
  side: THREE.BackSide, depthWrite: false, fog: false,
  uniforms: {
    uHorizon: { value: FOG_COLOR },
    uZenith: { value: new THREE.Color(0x020711) },
    uFloor: { value: new THREE.Color(0x04050a) },
    uMoonDir: { value: moonDir },
    uMoonTint: { value: new THREE.Color(0x8fa8d8) }
  },
  vertexShader: `
    varying vec3 vDir;
    void main(){
      vDir = position;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }`,
  fragmentShader: `
    uniform vec3 uHorizon, uZenith, uFloor, uMoonDir, uMoonTint;
    varying vec3 vDir;
    void main(){
      vec3 d = normalize(vDir);
      vec3 col = mix(uHorizon, uZenith, pow(clamp(d.y, 0.0, 1.0), 0.45));
      col = mix(uFloor, col, smoothstep(-0.22, 0.01, d.y));
      float m = clamp(dot(d, uMoonDir), 0.0, 1.0);
      col += uMoonTint * (pow(m, 400.0) * 0.6 + pow(m, 30.0) * 0.10);
      gl_FragColor = vec4(col, 1.0);
      #include <tonemapping_fragment>
      #include <colorspace_fragment>
    }`
});
const sky = new THREE.Mesh(new THREE.SphereGeometry(700, 40, 24), skyMat);
sky.renderOrder = -10;
scene.add(sky);
```

**Ground:**

```js
const groundGeo = new THREE.PlaneGeometry(150, 150, 84, 84);
groundGeo.rotateX(-Math.PI / 2);
{
  const p = groundGeo.attributes.position;
  const colors = new Float32Array(p.count * 3);
  const cA = new THREE.Color(0x20301b), cB = new THREE.Color(0x314224), cDirt = new THREE.Color(0x40301d), cDark = new THREE.Color(0x141c0e);
  const c = new THREE.Color();
  for (let i = 0; i < p.count; i++){
    const x = p.getX(i), z = p.getZ(i);
    p.setY(i, groundHeight(x, z));
    const d = Math.hypot(x, z);
    c.copy(cA).lerp(cB, THREE.MathUtils.clamp(0.5 + 0.5 * Math.sin(x * 0.33 + 2.1) * Math.cos(z * 0.29 + 0.7), 0, 1));
    c.lerp(cDark, THREE.MathUtils.clamp(0.35 + 0.35 * Math.sin(x * 0.81 + 5.0) * Math.sin(z * 0.9 + 1.3), 0, 0.6));
    c.lerp(cDirt, (1 - THREE.MathUtils.smoothstep(d, 1.3, 3.6)) * 0.85);
    colors[i*3] = c.r; colors[i*3+1] = c.g; colors[i*3+2] = c.b;
  }
  groundGeo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
}
groundGeo.computeVertexNormals();
const ground = new THREE.Mesh(groundGeo, new THREE.MeshStandardMaterial({ vertexColors: true, flatShading: true, roughness: 1, metalness: 0 }));
ground.receiveShadow = true;
scene.add(ground);
```

**Rocks:**

```js
function makeRock(size, color){
  const geo = new THREE.IcosahedronGeometry(size, 0);
  const p = geo.attributes.position;
  const seen = new Map();
  const v = new THREE.Vector3();
  for (let i = 0; i < p.count; i++){
    v.fromBufferAttribute(p, i);
    const key = v.x.toFixed(3) + '_' + v.y.toFixed(3) + '_' + v.z.toFixed(3);
    let o = seen.get(key);
    if (!o){ o = { x: rand(-1,1) * size * 0.4, y: rand(-0.6, 0.6) * size * 0.35, z: rand(-1,1) * size * 0.4 }; seen.set(key, o); }
    p.setXYZ(i, v.x + o.x, v.y + o.y, v.z + o.z);
  }
  geo.computeVertexNormals();
  const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ color, flatShading: true, roughness: 0.95 }));
  mesh.castShadow = mesh.receiveShadow = true;
  return mesh;
}
const rockPalette = [0x4b5158, 0x555b60, 0x3f464e, 0x5c6167];
function scatterRock(rMin, rMax, sMin, sMax, count){
  for (let i = 0; i < count; i++){
    const a = rand(0, Math.PI * 2), r = rand(rMin, rMax);
    const x = Math.cos(a) * r, z = Math.sin(a) * r;
    const s = rand(sMin, sMax);
    const rock = makeRock(s, rockPalette[(Math.random() * rockPalette.length) | 0]);
    rock.position.set(x, groundHeight(x, z) + s * 0.45, z);
    rock.rotation.set(rand(0, 0.4), rand(0, Math.PI * 2), rand(0, 0.4));
    rock.scale.set(rand(0.8, 1.3), rand(0.7, 1.1), rand(0.8, 1.3));
    scene.add(rock);
  }
}
scatterRock(3.2, 11.5, 0.2, 0.55, 9);   // stones
scatterRock(10, 15, 0.9, 1.5, 3);       // boulders
```

Ring stones:

```js
for (let i = 0; i < 10; i++){
  const a = (i / 10) * Math.PI * 2 + rand(-0.12, 0.12);
  const r = 1.08 + rand(-0.09, 0.09);
  const s = rand(0.16, 0.26);
  const rock = makeRock(s, rockPalette[(Math.random() * rockPalette.length) | 0]);
  rock.position.set(Math.cos(a) * r, s * 0.4, Math.sin(a) * r);
  rock.rotation.set(rand(0, 0.5), rand(0, 6.28), rand(0, 0.5));
  rock.scale.set(rand(0.9, 1.35), rand(0.6, 0.9), rand(0.9, 1.35));
  scene.add(rock);
}
```

**Logs & benches:**

```js
const barkMat = new THREE.MeshStandardMaterial({ color: 0x4a3524, flatShading: true, roughness: 1 });
function addFallenLog(len, rad, x, z, yaw){
  const geo = new THREE.CylinderGeometry(rad * 0.9, rad, len, 7);
  const m = new THREE.Mesh(geo, barkMat);
  m.position.set(x, groundHeight(x, z) + rad * 0.75, z);
  m.rotation.set(rand(-0.05, 0.05), yaw, Math.PI / 2);
  m.castShadow = m.receiveShadow = true;
  scene.add(m);
  return m;
}
for (let i = 0; i < 3; i++){
  const a = rand(0, 6.28), r = rand(5, 10.5);
  addFallenLog(rand(2.2, 3.6), rand(0.18, 0.3), Math.cos(a) * r, Math.sin(a) * r, rand(0, 6.28));
}
// sitting logs flanking the fire
addFallenLog(2.4, 0.21, Math.cos(3.6) * 2.75, Math.sin(3.6) * 2.75, 3.6 + Math.PI / 2);
addFallenLog(2.4, 0.21, Math.cos(0.9) * 2.9, Math.sin(0.9) * 2.9, 0.9 + Math.PI / 2);
```

Wait bench at angle 0.9 rad: (1.80, 2.27) — distance 2.9 ✓. And angle 3.6: (-2.21, -1.68) r 2.78 ✓.

**Firewood pile:**

```js
{
  const pile = new THREE.Group();
  const stickGeo = new THREE.CylinderGeometry(0.07, 0.09, 1.25, 6);
  stickGeo.rotateZ(Math.PI / 2);
  const spots = [[0, 0.08, -0.12], [0, 0.08, 0.02], [0, 0.08, 0.16], [0.05, 0.21, -0.05], [-0.06, 0.21, 0.07]];
  for (const s of spots){
    const m = new THREE.Mesh(stickGeo, barkMat);
    m.position.set(s[0], s[1], s[2]);
    m.rotation.x = rand(-0.06, 0.06);
    m.castShadow = m.receiveShadow = true;
    pile.add(m);
  }
  pile.position.set(2.35, groundHeight(2.35, -3.5), -3.5);
  pile.rotation.y = rand(0, 3);
  scene.add(pile);
}
```
Hmm stickGeo rotateZ(π/2): cylinder axis Y → rotate about Z by 90° tips axis to X ✓ length along x ✓.

**Tent:**

```js
{
  const shape = new THREE.Shape();
  shape.moveTo(-1.3, 0); shape.lineTo(1.3, 0); shape.lineTo(0, 1.75); shape.closePath();
  const tentGeo = new THREE.ExtrudeGeometry(shape, { depth: 3.0, bevelEnabled: false });
  tentGeo.translate(0, 0, -1.5);
  const tentMat = new THREE.MeshStandardMaterial({ color: 0x7d5b3e, flatShading: true, roughness: 0.9 });
  const tent = new THREE.Mesh(tentGeo, tentMat);
  const tx = 3.8, tz = -6.8;
  tent.position.set(tx, groundHeight(tx, tz) - 0.04, tz);
  tent.rotation.y = Math.atan2(-tx, -tz)?? 
```
Wait: front +z should face fire at origin: direction from tent to fire = (0 - tx, 0 - tz) = (-3.8, 6.8). rotation.y = atan2(dirX, dirZ) = atan2(-3.8, 6.8) ≈ -0.51 ✓. So `tent.rotation.y = Math.atan2(-tx, -tz);` ✓ (general: atan2(-tx, -tz)).

```js
  tent.castShadow = tent.receiveShadow = true;
  scene.add(tent);
  // dark opening
  const openShape = new THREE.Shape();
  openShape.moveTo(-0.95, 0); openShape.lineTo(0.95, 0); openShape.lineTo(0, 1.28); openShape.closePath();
  const open = new THREE.Mesh(new THREE.ShapeGeometry(openShape), new THREE.MeshBasicMaterial({ color: 0x060407 }));
  open.position.set(0, 0.03, 1.53);
  tent.add(open);
```
Careful: adding open as child of tent — child inherits rotation ✓ position local front +z=1.53 (tent front face at +1.5, tiny gap) ✓. MeshBasic ignores light → dark hole ✓. But fog applies (fine, near). Basic material with fog true fine.

Note: opening slightly inset horizontally (±0.95 < ±1.3) ✓ visible canvas frame around dark triangle ✓.

**Grass:**

```js
{
  const gGeo = new THREE.ConeGeometry(0.06, 0.55, 4, 1, true);
  gGeo.translate(0, 0.27, 0);
  const gMat = new THREE.MeshStandardMaterial({ color: 0xffffff, flatShading: true, roughness: 1, side: THREE.DoubleSide });
  const COUNT = 260;
  const grass = new THREE.InstancedMesh(gGeo, gMat, COUNT);
  grass.frustumCulled = false;
  const d = new THREE.Object3D();
  const c = new THREE.Color();
  const gA = new THREE.Color(0x27391c), gB = new THREE.Color(0x3c4d26);
  for (let i = 0; i < COUNT; i++){
    const a = rand(0, 6.28), r = 2.8 + Math.sqrt(Math.random()) * 13;
    const x = Math.cos(a) * r, z = Math.sin(a) * r;
    d.position.set(x, groundHeight(x, z) - 0.02, z);
    d.rotation.set(rand(-0.35, 0.35), rand(0, 6.28), rand(-0.35, 0.35));
    const s = rand(0.5, 1.5);
    d.scale.set(s, s * rand(0.8, 1.6), s);
    d.updateMatrix();
    grass.setMatrixAt(i, d.matrix);
    c.copy(gA).lerp(gB, Math.random());
    grass.setColorAt(i, c);
  }
  grass.instanceColor.needsUpdate = true;   // safe guard with if
  scene.add(grass);
}
```
openEnded cone true → no base cap needed ✓ side DoubleSide so visible both ✓.

`if (grass.instanceColor) grass.instanceColor.needsUpdate = true;` — setColorAt creates it ✓.

**Trees:**

```js
const spots = [];
{
  let guard = 0;
  while (spots.length < 52 && guard++ < 4000){
    const a = rand(0, Math.PI * 2);
    const r = 20 + Math.sqrt(Math.random()) * 30;
    const x = Math.cos(a) * r, z = Math.sin(a) * r;
    let ok = true;
    for (const p of spots){ const dx = p.x - x, dz = p.z - z; if (dx*dx + dz*dz < 7.3){ ok = false; break; } } // 2.7^2
    if (ok) spots.push({ x, z, s: rand(0.8, 1.5), h: rand(0.9, 1.25), rot: rand(0, 6.28), pine: Math.random() < 0.7 });
  }
}
const pines = spots.filter(s => s.pine);
const leafs = spots.filter(s => !s.pine);

const trunkMat = new THREE.MeshStandardMaterial({ color: 0xffffff, flatShading: true, roughness: 1 });
const leafMat = new THREE.MeshStandardMaterial({ color: 0xffffff, flatShading: true, roughness: 0.95 });

/* pine parts */
const pineTrunk = new THREE.InstancedMesh(
  (() => { const g = new THREE.CylinderGeometry(0.13, 0.22, 1.7, 5); g.translate(0, 0.85, 0); return g; })(),
  trunkMat, pines.length);
const pineLevels = [[1.35, 2.0, 0.45], [1.02, 1.7, 1.45], [0.64, 1.45, 2.42]].map(([r, h, y]) => {
  const g = new THREE.ConeGeometry(r, h, 7); g.translate(0, y + h / 2, 0); return g;
});
const pineCones = pineLevels.map(g => { const im = new THREE.InstancedMesh(g, leafMat, pines.length); im.frustumCulled = false; im.castShadow = true; scene.add(im); return im; });
pineTrunk.frustumCulled = false; pineTrunk.castShadow = true;
scene.add(pineTrunk);
```

Hmm careful: if pines.length could be 0 (unlikely) — InstancedMesh count 0 edge: still fine? To be safe if 0, guard: `Math.max(1, pines.length)`? If count 0, three might warn... 52 spots 70% pine → ~36 expected; chance of 0 pines ≈ 0. Skip guard... I'll still guard count = Math.max(pines.length, 1) harmless? Then setMatrixAt for index 0 skipped — instance matrix identity at origin → a default-size pine at origin would appear in the fire! Bad. Keep exact count; probability all 52 non-pine = 0.3^52 ≈ 0. Fine.

Fill matrices:

```js
const dum = new THREE.Object3D();
const cTmp = new THREE.Color();
const trunkColA = new THREE.Color(0x3a2a1c), trunkColB = new THREE.Color(0x54402a);
const pineCols = [0x1c3a24, 0x214a2b, 0x18321f, 0x1e4433].map(h => new THREE.Color(h));

pines.forEach((p, i) => {
  dum.position.set(p.x, groundHeight(p.x, p.z) - 0.06, p.z);
  dum.rotation.set(0, p.rot, 0);
  dum.scale.set(p.s, p.s * p.h, p.s);
  dum.updateMatrix();
  pineTrunk.setMatrixAt(i, dum.matrix);
  cTmp.copy(trunkColA).lerp(trunkColB, Math.random());
  pineTrunk.setColorAt(i, cTmp);
  const fc = pineCols[(Math.random() * pineCols.length) | 0].clone().offsetHSL(rand(-0.02, 0.02), rand(-0.08, 0.08), rand(-0.03, 0.03));
  pineCones.forEach((im, k) => {
    im.setMatrixAt(i, dum.matrix);
    im.setColorAt(i, cTmp.copy(fc).multiplyScalar(0.78 + k * 0.16));
  });
});
[pineTrunk, ...pineCones].forEach(im => { im.instanceMatrix.needsUpdate = true; if (im.instanceColor) im.instanceColor.needsUpdate = true; });
```
Wait setColorAt must be called with distinct color objects? setColorAt copies values into instanceColor buffer — reuse of cTmp fine ✓.

Careful: `cTmp.copy(fc)` overwrites cTmp after trunk use — trunk already written ✓.

**Deciduous:**

```js
const leafTrunkGeo = (() => { const g = new THREE.CylinderGeometry(0.15, 0.26, 2.4, 5); g.translate(0, 1.2, 0); return g; })();
const leafTrunks = new THREE.InstancedMesh(leafTrunkGeo, trunkMat, leafs.length);
leafTrunks.castShadow = true; leafTrunks.frustumCulled = false;
scene.add(leafTrunks);
const blobGeo = new THREE.IcosahedronGeometry(1.25, 0);
const blobs = new THREE.InstancedMesh(blobGeo, leafMat, leafs.length * 3);
blobs.castShadow = true; blobs.frustumCulled = false;
scene.add(blobs);

const blobDefs = [
  { p: new THREE.Vector3(0, 2.85, 0), s: 1.0 },
  { p: new THREE.Vector3(0.8, 2.2, 0.45), s: 0.62 },
  { p: new THREE.Vector3(-0.65, 2.45, -0.4), s: 0.55 },
];
const leafCols = [0x27432a, 0x2e4a22, 0x22391f, 0x2f4d33].map(h => new THREE.Color(h));
const q = new THREE.Quaternion(), e = new THREE.Euler();
const local = new THREE.Matrix4();
leafs.forEach((p, i) => {
  dum.position.set(p.x, groundHeight(p.x, p.z) - 0.06, p.z);
  dum.rotation.set(0, p.rot, 0);
  dum.scale.set(p.s, p.s * p.h, p.s);
  dum.updateMatrix();
  leafTrunks.setMatrixAt(i, dum.matrix);
  cTmp.copy(trunkColA).lerp(trunkColB, Math.random());
  leafTrunks.setColorAt(i, cTmp);
  const fc = leafCols[(Math.random() * leafCols.length) | 0].clone().offsetHSL(rand(-0.02, 0.02), rand(-0.06, 0.06), rand(-0.02, 0.04));
  blobDefs.forEach((b, k) => {
    e.set(rand(0, 6.28), rand(0, 6.28), rand(0, 6.28));
    q.setFromEuler(e);
    local.compose(b.p, q, new THREE.Vector3(b.s, b.s * rand(0.8, 1.05), b.s));
    blobs.setMatrixAt(i * 3 + k, dum.matrix.clone().multiply(local));
    blobs.setColorAt(i * 3 + k, cTmp.copy(fc).multiplyScalar(rand(0.85, 1.1)));
  });
});
[leafTrunks, blobs].forEach(im => { im.instanceMatrix.needsUpdate = true; if (im.instanceColor) im.instanceColor.needsUpdate = true; });
```
blob y positions: trunk 2.4 top; blob1 at 2.85 radius 1.25*~1... blob1 center 2.85 with radius 1.25 → spans 1.6-4.1 ✓ overlapping trunk top ✓. Height total ~4.1*s ✓.

**Fire assembly:**

```js
/* charred ground */
const pit = new THREE.Mesh(new THREE.CircleGeometry(1.02, 10), new THREE.MeshStandardMaterial({ color: 0x120b06, roughness: 1 }));
pit.rotation.x = -Math.PI / 2; pit.position.y = 0.012;
pit.receiveShadow = true;
scene.add(pit);

/* teepee logs */
const charMat = new THREE.MeshStandardMaterial({ color: 0x241610, flatShading: true, roughness: 1 });
const teepeeGeo = (() => { const g = new THREE.CylinderGeometry(0.07, 0.1, 1.55, 6); g.rotateX(Math.PI / 2); g.translate(0, 0, 0.77); return g; })();
for (let i = 0; i < 5; i++){
  const a = (i / 5) * Math.PI * 2 + 0.35;
  const log = new THREE.Mesh(teepeeGeo, charMat);
  log.position.set(Math.cos(a) * 0.55, 0.1, Math.sin(a) * 0.55);
  log.lookAt(0, 1.3, 0);
  log.castShadow = true;
  scene.add(log);
}
```
Check rotateX(π/2): axis Y → after rotateX by 90°, Y axis maps to +Z? rotateX(π/2) rotates Y toward Z: point (0,1,0) → (0, cos90*1 - sin90*0?, ...) rotation about X: y' = y cosθ - z sinθ; z' = y sinθ + z cosθ. θ=90°: (0,1,0) → y'=0-0=0? y' = 1*0 - 0*1 = 0; z' = 1*1 + 0*0 = 1 → (0,0,1) ✓ axis → +Z ✓. translate(0,0,0.77): base(origin at -0.77→0, extends 0→1.55) ✓.

**Embers:**

```js
const emberMat = new THREE.MeshStandardMaterial({ color: 0x1a0c06, emissive: 0xff5a1e, emissiveIntensity: 2, flatShading: true, roughness: 0.7 });
const emberGeo = new THREE.DodecahedronGeometry(1, 0);
for (let i = 0; i < 7; i++){
  const e = new THREE.Mesh(emberGeo, emberMat);
  const a = rand(0, 6.28), r = Math.sqrt(Math.random()) * 0.45;
  const s = rand(0.07, 0.16);
  e.position.set(Math.cos(a) * r, 0.05 + s * 0.4, Math.sin(a) * r);
  e.scale.setScalar(s);
  e.rotation.set(rand(0, 6.28), rand(0, 6.28), 0);
  scene.add(e);
}
```

**Flames** (as above with hash instead of aRand):

vertex:
```glsl
uniform float uTime, uPhase, uHeight;
varying float vH;
float hash(vec2 p){ return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
void main(){
  vec3 p = position;
  float h = clamp(p.y / uHeight, 0.0, 1.0);
  vH = h;
  float r = hash(position.xy);
  float t = uTime * 1.7 + uPhase * 7.0 + r * 6.2831;
  float swayX = sin(t * 1.35 + uPhase) * 0.16 + sin(t * 6.1 + p.y * 5.0) * 0.11;
  float swayZ = cos(t * 1.15 + uPhase) * 0.14 + cos(t * 5.3 + p.y * 4.3) * 0.11;
  p.x += swayX * h * h;
  p.z += swayZ * h * h;
  p.y *= 1.0 + 0.07 * sin(t * 4.6 + uPhase);
  gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}
```

Hmm `hash(position.xy)` — cone positions: same (x,y) for different z? Cone vertices: (x, y, z) triplets; two verts with same xy but ±z exist → different wobble front/back → crack along? The seam issue returns (front/back displaced differently). Add z: hash(position.xz + position.yy)? Use vec3 hash: hash(vec3). Let me hash all three: `fract(sin(dot(p, vec3(12.9898, 78.233, 37.719))) * 43758.5453)` with p=position ✓ position-unique ✓ seam-safe.

```glsl
float hash3(vec3 p){ return fract(sin(dot(p, vec3(12.9898, 78.233, 37.719))) * 43758.5453); }
```

fragment:
```glsl
uniform vec3 uColA, uColB;
uniform float uIntensity;
varying float vH;
void main(){
  vec3 col = mix(uColA, uColB, smoothstep(0.06, 0.72, vH));
  float a = pow(1.0 - smoothstep(0.3, 1.0, vH), 1.25);
  gl_FragColor = vec4(col * uIntensity, a * 0.9);
  #include <colorspace_fragment>
}
```

Material params: transparent true, depthWrite false, blending Additive, side DoubleSide.

flame mesh creation with userData.phase for scale anim.

**Glow sprite** as above.

**Sparks:**

```js
const sparkGeo = new THREE.BufferGeometry();
{
  const N = 150;
  sparkGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(N * 3), 3));
  const seed = new Float32Array(N), speed = new Float32Array(N), angle = new Float32Array(N);
  for (let i = 0; i < N; i++){ seed[i] = Math.random(); speed[i] = rand(0.16, 0.5); angle[i] = rand(0, 6.28); }
  sparkGeo.setAttribute('aSeed', new THREE.BufferAttribute(seed, 1));
  sparkGeo.setAttribute('aSpeed', new THREE.BufferAttribute(speed, 1));
  sparkGeo.setAttribute('aAngle', new THREE.BufferAttribute(angle, 1));
}
const sparkMat = new THREE.ShaderMaterial({
  transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
  uniforms: { uTime: uniTime, uScale: uniScale },
  vertexShader: `
    attribute float aSeed, aSpeed, aAngle;   // GLSL1: can't comma-declare different? "attribute float aSeed, aSpeed;" valid GLSL? Comma lists allowed in GLSL declarations ✓ but attributes must be declared separately? `attribute float a, b;` — allowed in GLSL ES 1.0? I believe multiple declarators per attribute declaration are allowed. To be safe, separate lines.
  `
});
```
I'll write separate lines for each attribute.

spark vertex:
```glsl
attribute float aSeed;
attribute float aSpeed;
attribute float aAngle;
uniform float uTime;
uniform float uScale;
varying float vLife;
void main(){
  float life = fract(uTime * aSpeed + aSeed);
  vLife = life;
  float rise = 2.0 + aSeed * 3.2;
  float r = 0.12 + life * (0.35 + fract(aSeed * 7.13) * 0.9);
  float ang = aAngle + life * (fract(aSeed * 3.71) - 0.5) * 4.0;
  vec3 p = vec3(cos(ang) * r, 0.22 + life * rise, sin(ang) * r);
  p.x += sin(life * 22.0 + aSeed * 40.0) * 0.09 * life;
  p.z += cos(life * 19.0 + aSeed * 34.0) * 0.09 * life;
  vec4 mv = modelViewMatrix * vec4(p, 1.0);
  float size = (0.05 + fract(aSeed * 13.7) * 0.03) * (1.0 - life * 0.72);
  gl_PointSize = max(size * uScale / -mv.z, 0.5);
  gl_Position = projectionMatrix * mv;
}
```
Points object positioned at origin (fire) ✓. `points.frustumCulled = false` ✓ (positions all zero).

spark fragment:
```glsl
varying float vLife;
void main(){
  vec2 uv = gl_PointCoord - 0.5;
  float d = length(uv) * 2.0;
  float a = smoothstep(1.0, 0.1, d);
  a *= smoothstep(0.0, 0.06, vLife) * (1.0 - smoothstep(0.55, 1.0, vLife));
  vec3 c = mix(vec3(1.0, 0.82, 0.42), vec3(1.0, 0.28, 0.04), smoothstep(0.1, 0.85, vLife));
  gl_FragColor = vec4(c * 1.7, a);
  #include <colorspace_fragment>
}
```

**Fireflies:**

```js
const flyGeo = new THREE.BufferGeometry();
{
  const N = 26;
  const pos = new Float32Array(N * 3), seed = new Float32Array(N * 4);
  for (let i = 0; i < N; i++){
    const a = rand(0, 6.28), r = 3 + Math.sqrt(Math.random()) * 11;
    pos[i*3] = Math.cos(a) * r; pos[i*3+1] = rand(0.5, 3.0); pos[i*3+2] = Math.sin(a) * r;
    seed[i*4] = Math.random(); seed[i*4+1] = Math.random(); seed[i*4+2] = Math.random(); seed[i*4+3] = Math.random();
  }
  flyGeo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
  flyGeo.setAttribute('aSeed', new THREE.BufferAttribute(seed, 4));
}
fly vertex:
attribute vec4 aSeed;
uniform float uTime;
uniform float uScale;
varying float vA;
void main(){
  vec3 p = position;
  p.x += sin(uTime * (0.24 + aSeed.x * 0.3) + aSeed.y * 6.28) * (0.9 + aSeed.z * 1.4);
  p.y += sin(uTime * (0.19 + aSeed.y * 0.33) + aSeed.x * 6.28) * 0.55;
  p.z += cos(uTime * (0.21 + aSeed.z * 0.27) + aSeed.w * 6.28) * (0.9 + aSeed.y * 1.4);
  float blink = pow(max(sin(uTime * (0.5 + aSeed.w * 1.1) + aSeed.y * 6.28), 0.0), 3.0);
  vA = blink;
  vec4 mv = modelViewMatrix * vec4(p, 1.0);
  gl_PointSize = (0.045 + 0.045 * blink) * uScale / -mv.z;
  gl_Position = projectionMatrix * mv;
}
fly fragment:
varying float vA;
void main(){
  vec2 uv = gl_PointCoord - 0.5;
  float d = length(uv) * 2.0;
  float a = smoothstep(1.0, 0.15, d) * vA;
  gl_FragColor = vec4(vec3(0.72, 0.95, 0.35) * 1.5, a);
  #include <colorspace_fragment>
}
```

**Smoke:**

```js
const smokeTex = radialTex([[0, 'rgba(200,192,182,0.42)'], [0.55, 'rgba(160,152,146,0.14)'], [1, 'rgba(120,114,110,0)']]);
const smokes = [];
for (let i = 0; i < 7; i++){
  const m = new THREE.SpriteMaterial({ map: smokeTex, transparent: true, depthWrite: false, opacity: 0, fog: false, rotation: rand(0, 6.28) });
  const s = new THREE.Sprite(m);
  s.userData = { seed: Math.random(), speed: rand(0.055, 0.1), dx: rand(-0.35, 0.35), dz: rand(-0.35, 0.35), spin: rand(-0.25, 0.25) };
  scene.add(s);
  smokes.push(s);
}
```

tick smoke:
```js
for (const s of smokes){
  const u = s.userData;
  const life = (t * u.speed + u.seed) % 1;
  s.position.set(u.dx + life * 0.9 + Math.sin(t * 0.4 + u.seed * 9.0) * 0.25, 0.75 + life * 5.3, u.dz + life * 0.7);
  const sc = 0.7 + life * 3.6;
  s.scale.set(sc, sc, 1);
  s.material.opacity = Math.sin(life * Math.PI) * 0.085;
  s.material.rotation += dt * u.spin;
}
```

**Animation:**

```js
const clock = new THREE.Clock();
let ready = false;
const loadingEl = document.getElementById('loading');
const hintEl = document.getElementById('hint');
let interacted = false;
controls.addEventListener('start', () => {
  controls.autoRotate = false;
  if (!interacted){ interacted = true; hintEl.classList.add('hide'); }
});

renderer.setAnimationLoop(() => {
  const dt = Math.min(clock.getDelta(), 0.05);
  const t = clock.elapsedTime;
  uniTime.value = t;

  const fl = Math.sin(t * 8.7) * 0.5 + Math.sin(t * 22.3 + 1.7) * 0.22 + Math.sin(t * 3.9 + 0.4) * 0.28 + Math.sin(t * 14.1 + 2.9) * 0.18;
  fireLight.intensity = 230 * (1 + 0.13 * fl);
  fireLight.position.set(Math.sin(t * 13.7) * 0.08, 1.25 + Math.sin(t * 9.1) * 0.05, Math.cos(t * 11.9) * 0.08);
  emberMat.emissiveIntensity = 1.8 + fl * 0.9;
  glow.material.opacity = 0.28 + fl * 0.055;
  const gs = 5.7 + fl * 0.55;
  glow.scale.set(gs, gs, 1);

  for (const f of flameMeshes){
    const ph = f.userData.phase;
    f.rotation.y += dt * 0.7;
    const b = Math.sin(t * 9.1 + ph);
    f.scale.set(1 - b * 0.05, 1 + b * 0.075, 1 - Math.cos(t * 7.7 + ph) * 0.05);
  }

  for (const s of smokes){ ... }

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

  if (!ready){ ready = true; loadingEl.style.opacity = '0'; setTimeout(() => loadingEl.remove(), 750); }
});
```

clock.elapsedTime — getDelta updates elapsedTime ✓.

**Resize handler** as planned; also note `uniPR.value` for stars.

Let me double check star shader uses uPR:
vertex: `gl_PointSize = aSize * uPR * (0.8 + 0.35 * tw);` hmm tw computed ✓.

Also star twinkle vAlpha.

Star positions y>0 hemisphere ✓.

**Check flame group y:** flames cones at group y=0.14; main flame height 1.75*1.075 ≈ 1.9 → tips ~2.0. Fire light at 1.25 inside flames ✓. Teepee logs to ~1.35. Glow at 1.05. Sparks rise to ~5.2 ✓. Nice.

Wait, one visual concern: the flame cone tips look like cones (pointy). With wobble + additive fade (alpha 0 at vH≥1... a=pow(1-smoothstep(0.3,1,vH),1.25): at vH=1 → a=0 ✓ tip fades out ✓ tip pointed fade good.

**Camera near 0.1 vs flame closeup:** minDistance 5 — fine.

**Check maxPolarAngle keeps above ground even at maxDistance 19 with terrain max ~1.4:** camera min y = 1.1 + 19*cos(1.5008) = 1.1 + 19*0.0676?? cos(π/2 - 0.07) = sin(0.07) = 0.0699 → 1.1+1.33 = 2.43 > terrain 1.4 + tree? Trees at ≥20 outside orbit ✓. Ground max at horizontal 18.99: amplitude*flat(=1) up to 1.48 → 2.43 > 1.48 ✓ ok.

**Fog vs orbiting:** camera max 19 < fog near 20 ✓ scene never fogged from camera POV weirdly ✓.

**Moon light target:** default target (0,0,0) — must add target to scene for matrix update? DirectionalLight target default is Object3D at origin — if not added to scene, its matrixWorld never updates but stays identity at origin ✓ fine.

**Now — potential console warning: `Material has no attribute 'aColor'...`?** No warning; shader uses attribute present ✓.

Also `THREE.BufferAttribute` for seed arrays ✓.

**Points materials & fog:** ShaderMaterial default fog:false ✓ no fog chunks → stars not fogged ✓ good.

**One more consideration: color banding on the sky gradient at night (dark) — dithering absent → banding possible.** ACES + gradient in dark blues might band on 8-bit. Mitigate: add tiny noise dithering in sky fragment: `col += (hash(gl_FragCoord.xy) - 0.5) * 0.004;` Let me add simple fract-sin hash on gl_FragCoord — cheap, kills banding:

```glsl
float n = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453);
col += (n - 0.5) * 0.005;
```
Hmm but then tonemapping/colorspace applied after ✓ fine. Add before gl_FragColor assignment. Actually careful: col could go slightly negative → clamp? values are ≥ 0.02-ish so -0.0025 fine.

Also the fogged ground→sky horizon match: dithering may create slight mismatch vs undithered fog — negligible.

**Also — the vignette CSS over canvas darkens edges — good for banding too.**

**Double check hemisphere light + ACES + dark scene not pure black:** ground albedo ~0.1-0.15; hemi 0.55 + dir 0.55 → roughly 0.55*(sky 0.2 + ...) — visible dim gray-green ✓. Add slight AmbientLight 0x0e1420 @ 0.4? Hemisphere probably enough; I'll add small ambient 0x223344 intensity 0.25 to lift shadows a touch... Hmm risk of washing. I'll skip ambient; hemi + dir + fire is a good trio.

Actually — one more check on hemisphere intensity in physical mode: HemisphereLight intensity is in "physical" units too (multiplied by π? In physically correct mode, light intensity is scaled by... For Hemisphere/Ambient/Directional, intensity is straightforward radiance multiplier). 0.55 dim → fine.

**Star density:** 1100 across sky dome visible ~half → good.

**Perf star points 1100 + sparks 150 + flies 26 ✓.**

**Now think about whether transparent flame cones z-sort with additive smoke sprites etc:** all additive except smoke (normal). Sorting among transparent: by distance; smoke vs flames overlapping → smoke drawn... depth sorted painterly; additive layers commutative among themselves; smoke normal blending order matters vs flames: if smoke drawn before flames: flames add over smoke ✓ fine either way (smoke faint).

**Ground receives point shadow — bias -0.006 + normalBias 0.04: with flat-shaded ground (derivative normals) — shadow lookup uses geometric position ✓ fine.**

One more possible artifact: point light shadow "peter-panning" from normalBias 0.04 — negligible.

**Instanced foliage receiveShadow false** — set explicitly? default false ✓. Trunks default false ✓. Rocks/logs receive ✓.

Grass receiveShadow false ✓ default (castShadow false too).

**Tent: ExtrudeGeometry shading — flatShading true ✓; extrude normals per face already (it generates smooth side normals? Extrude sides have per-face normals... flatShading forces facets ✓).**

**Opening ShapeGeometry basic black — with fog: default fog true; tent ~19 from camera — negligible fog ✓.**

Now, hmm, `ShapeGeometry` faces +z ✓ (shapes drawn in XY plane facing +z) ✓.

**Tent cast shadow onto itself?** self-shadow with flatShading + dir light — minor acne could show on tent faces with bias... normalBias handles ✓.

**Final check on tone-mapped sky vs stars additive:** stars drawn over sky ✓.

**Regarding `renderer.setAnimationLoop` + OrbitControls damping ✓.**

**Mobile touch:** OrbitControls touch ✓ hint mentions drag ✓.

I think the plan is solid. Let me also make sure the **hint text about autorotate**: "drag to orbit · scroll to zoom" ✓.

**Edge: `backdrop-filter` in iframe — fine, degrades gracefully.**

**Write final file.** Also ensure NO stray `<` `>` inside shader strings that could break HTML parsing? Inside <script> module, `<` in code like `a < b` is fine in HTML (script content is CDATA-ish raw text until `</script`) ✓. Avoid the literal sequence `</script` in strings ✓.

Also careful with template literals containing `${` — my shader strings don't use `${`. ✓

One last completeness check against requirements:

1. ✅ Ground with low-poly rocks and logs around central campfire.
2. ✅ ≥30 low-poly trees (52) random, not on fire (r≥20).
3. ✅ Animated flames (shader wobble + scale pulse) + warm flickering point light with shadows.
4. ✅ Sparks particle system rising from fire.
5. ✅ Dark night sky with stars + moon + fog fading distant trees.
6. ✅ OrbitControls with maxPolarAngle limit above ground + distance limits.
7. ✅ Renderer fills window + resize handler.
8. ✅ Import map exactly as given.
9. ✅ Single file, no external assets besides three CDN, canvas-generated textures only.
10. ✅ Visible at first paint (loading → scene quickly), 1280×800 + 375px, no horizontal scroll.

Extra flourishes: tent, fireflies, smoke, glow halo, grass, moon halo, auto-orbit, twinkle, vignette, title.

Let me now also dial a couple of aesthetic numbers I'm unsure about:
- Flame heights: main 1.75 tall at world scale where logs are 1.55 — flames tower above teepee a bit — realistic campfire flames ~1-2× log height ✓.
- Fire light 230 candela — with ACES: ground at 2.5m → 230/6.25 ≈ 37 lux → tone mapped ~0.85 → bright warm ground near fire ✓.

- fogColor 0x0d1526 (dark blue) ✓ horizon slightly lighter than zenith 0x020711 ✓.

Also, **verify smoothstep usage**: THREE.MathUtils.smoothstep(x, min, max) — returns smooth 0..1 ✓. In sky shader `smoothstep(-0.22, 0.01, d.y)` GLSL built-in with (edge0, edge1, x) — order: GLSL smoothstep(edge0, edge1, x) ✓ I wrote smoothstep(-0.22, 0.01, d.y) ✓ correct.

In flame fragment: smoothstep(0.06, 0.72, vH) ✓; smoothstep(0.3, 1.0, vH) ✓.

Sparks fragment smoothstep(1.0, 0.1, d) — edge0 > edge1 works in GLSL? smoothstep with edge0>edge1 is undefined per spec (clamp((x-e0)/(e1-e0))) — division negative — actually it computes t = clamp((x - edge0) / (edge1 - edge0), 0, 1); with edge0=1.0, edge1=0.1: (d-1)/(-0.9) → at d=0 → (-1)/(-0.9)=1.11 clamp→1; at d=1 → 0 ✓ works numerically on all GPUs (it's defined by formula; spec says results undefined if edge0 ≥ edge1 but universally computed as the formula — safer to write `1.0 - smoothstep(0.1, 1.0, d)`. Let me use the safe form everywhere:

- sparks: `float a = 1.0 - smoothstep(0.1, 1.0, d);`? Original intent smoothstep(1.0, 0.1, d) = 1 at center, 0 at edge. Safe equivalent: `1.0 - smoothstep(0.1, 1.0, d)` — at d=0: 1-0=1 ✓; d=1: 1-1=0 ✓.
- stars: `smoothstep(1.0, 0.15, d)` → replace `1.0 - smoothstep(0.15, 1.0, d)`.
- fireflies: same fix.
- flame: `1.0 - smoothstep(0.3, 1.0, vH)` ✓ already safe.

Also MathUtils.smoothstep fine.

**GLSL version note:** three r170 uses WebGL2 by default; ShaderMaterial compiles as GLSL ES 3.0? No — three converts built-in to GLSL3 only if material.glslVersion = THREE.GLSL3; default keeps GLSL1-style (attribute/varying) with WebGL2 via compatibility prefixes (three injects #version 300 es? No — default ShaderMaterial uses GLSL1 syntax compiled as ES 1.00 unless glslVersion set. WebGL2 supports ES 1.00 shaders ✓. `gl_FragColor` valid ✓.

**Hash function in vertex shader uses `position` (attribute) ✓ fine.**

Let me also double check `CircleGeometry(1.02, 10)` rotate -PI/2 faces +y ✓ (circle faces +z; rotate x -90 → +y ✓).

**pit receiveShadow with standard material ✓.**

Also — should the pit be affected by fog? yes near center negligible ✓ default.

**Teepee lookAt while logs are scene children (origin) ✓.**

**Ember dodecahedron flatShading ✓ emissive ✓ no shadows.**

**Order of code:** controls needs renderer.domElement — define renderer first ✓.

**uniScale initial:** compute in onResize() called before first frame ✓.

**uniPR initial:** set at creation from renderer.getPixelRatio() ✓ and updated on resize ✓.

Now let me also think — **fireLight shadow & flame cones**: cones don't cast ✓ but teepee logs DO cast → moving flicker shadows through log gaps ✓ beautiful.

**The glow sprite might make flames look washed if too strong:** opacity ~0.28-0.33 with gradient peak 0.85 alpha at center — additive contribution ~0.24 orange at center over 5.6 units — moderate halo ✓.

Also **check sprite additive + tonemapping:** SpriteMaterial is built-in → toneMapped default true → halo dimmed by ACES — fine.

Star ShaderMaterial not tonemapped → bright crisp ✓.

**One more possible issue — moon lit by nothing:** MeshBasicMaterial → constant bright ✓.

**Halo sprite size 150 at distance 580:** angular radius atan(75/580) ≈ 7.4° — halo reaches ~moon 3° diameter + soft 7° glow ✓ tasteful.

**Star sizes up to 4.4 px at pr 2 → 8.8 device px ✓ fine.**

Let me write out everything now in final form. I'll keep code organized with section comments. Also add a subtle detail: **a small flat "stone slab" near benches?** no, enough.

Also I want to double check `controls.autoRotateSpeed = -0.45` — gentle 0.45°... autoRotateSpeed 2.0 = 30s/orbit? Docs: 2.0 → 30 seconds per orbit at 60fps. So 0.45 → ~133s/orbit — very gentle drift ✓ maybe 0.6 (100s). Use -0.6.

**Also verify no `localStorage` etc — none ✓. No alert ✓.**

**Potential runtime error spots to review at the end:**
- All uniform names consistent between JS/GLSL.
- All attribute names consistent.
- No typos in chunk includes.
- `new THREE.InstancedMesh(geo, mat, count)` with count > 0 ✓.
- `im.instanceColor` exists after setColorAt ✓ guarded.

Also `pineCones.forEach((im, k) => ...)` uses cTmp copy with multiplyScalar ✓.

One subtlety: `leafMat`/`trunkMat` shared between pine cones/decid blobs with white base color — instance colors carry actual ✓ but shading of foliage vs trunk same roughness ✓ fine.

**Deciduous blobs receiveShadow false default ✓.**

Also — **the moonlight dir light with castShadow**: InstancedMesh castShadow ✓ supported.

**Shadow camera setup code:**

```js
moonLight.shadow.camera.left = -62;
moonLight.shadow.camera.right = 62;
moonLight.shadow.camera.top = 62;
moonLight.shadow.camera.bottom = -62;
```

**Alright — also the requirement "warm point light that flickers and casts shadows" ✓.**

Let me also add **`renderer.info` debug? no.**

Time to write the final file. I'll write ~700 lines total HTML. Also final check of the tree count printed? Not needed.

One more thought — **fog + flame ShaderMaterial:** flame cones are within 2m of camera path? Fog far 20 — flame unaffected ✓ (fog false anyway).

**Tent + fire shadow:** teepee logs cast moving shadows over tent — nice.

**Grass color at night:** instance colors darkish green lit by fire near center ✓.

Writing now.

Actually, let me reconsider the star twinkle alpha formula:

```glsl
float tw = 0.5 + 0.5 * sin(uTime * aTw + aPhase);
vAlpha = mix(1.0, 0.35 + 0.65 * tw, 0.8);
```
so all stars twinkle somewhat: alpha 0.35..1 ✓.

And size flicker small ✓.

**Firefly count 26, radius up to 14 — some among tree trunks at 20? no, 14 max — in clearing ✓.**

**Also verify smoke doesn't obscure moon**: smoke rises ~6 high near center; camera horizontal-ish → smoke stays lower center screen ✓.

**Final: initial camera before controls.update(): controls.update() with autoRotate will rotate slightly; also it enforces polar/distance — position (8.5,3,14) distance to target (0,1.1,0): sqrt(72.25 + 3.61 + 196) = sqrt(271.86) ≈ 16.49 ✓ within [5,19] ✓ polar: acos((3-1.1)/16.49) = acos(0.115) = 83.4° ✓ < 86 ✓.

Good. Compose final answer with one code block. Let me write it carefully, keeping syntax valid throughout. During writing, double check every GLSL bracket.

Also: HTML entities in JS? Inside <script>, fine.

For the `#loading` fade: after `renderer.setAnimationLoop` callback runs first time — remove loading. Also guard errors: if WebGL fails, loading remains with message — acceptable.

Also set `scene.background` — sky mesh covers everything, but during first frames before? fine.

Write it:

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

/* ---------- shared time / size uniforms ---------- */
const uniTime = { value: 0 };
const uniScale = { value: 1 };
const uniPR = { value: 0 };

/* ---------- renderer / scene / camera ---------- */
...
```

Hmm uniPR initial set after renderer creation: uniPR.value = renderer.getPixelRatio().

Also one thing in `onResize`: uniPR.value = renderer.getPixelRatio() after setPixelRatio call ✓.

Full code (writing final):

Also — I realize I should double-check `THREE.Fog` constructor accepts Color instance: `new THREE.Fog(color, near, far)` — color can be Color/hex ✓.

Also `scene.fog = new THREE.Fog(FOG_COLOR, 20, 82)` shares the Color instance with sky uniform — both read-only ✓ fine.

Also careful — `uHorizon: { value: FOG_COLOR }` — same Color object ✓.

Let me also confirm PlaneGeometry attribute `position` after rotateX: y values all 0, x/z spread ✓ we setY per vertex ✓.

84×84 segments → 85² = 7225 verts, 84²*2 = 14112 tris ✓ fine.

Also — `groundHeight` for grass/tree placement near plane edges fine.

**Deciduous blob geometry radius 1.25 with scale to ~1.25*s — width ~2.5*s (s up to 1.5 → 3.75) big crowns ✓.**

**Tree total heights:** pine up to 3.87*1.5*1.25(h jitter max)?? s max 1.5 × h max 1.25 → 7.3 tall max, min 0.8*0.9*3.87 ≈ 2.8 ✓ variety.

OK writing final answer now.

One more: `controls.minPolarAngle = 0.15` — nearly top-down allowed ✓ fine.

For safety in the sandbox: wrap module code in try/catch? If it throws, nothing renders... The code must be correct; I'll be careful. No try/catch needed; but a top-level error would leave loading screen — at least visible message. Fine.

Also double-check: `moon.rotation` — static ok.

Let me finalize the flame material function:

```js
function flameMaterial(phase, height, intensity, colA, colB){
  return new THREE.ShaderMaterial({
    transparent: true,
    depthWrite: false,
    blending: THREE.AdditiveBlending,
    side: THREE.DoubleSide,
    uniforms: {
      uTime: uniTime,
      uPhase: { value: phase },
      uHeight: { value: height },
      uIntensity: { value: intensity },
      uColA: { value: new THREE.Color(colA) },
      uColB: { value: new THREE.Color(colB) }
    },
    vertexShader: FLAME_VERT,
    fragmentShader: FLAME_FRAG
  });
}
```

Flame list:

```js
const FLAME_VERT = `
  uniform float uTime;
  uniform float uPhase;
  uniform float uHeight;
  varying float vH;
  float hash3(vec3 p){ return fract(sin(dot(p, vec3(12.9898, 78.233, 37.719))) * 43758.5453); }
  void main(){
    vec3 p = position;
    float h = clamp(p.y / uHeight, 0.0, 1.0);
    vH = h;
    float r = hash3(position);
    float t = uTime * 1.7 + uPhase * 7.0 + r * 6.2831;
    float sx = sin(t * 1.35 + uPhase) * 0.16 + sin(t * 6.1 + p.y * 5.0) * 0.11;
    float sz = cos(t * 1.15 + uPhase * 1.7) * 0.14 + cos(t * 5.3 + p.y * 4.3) * 0.11;
    p.x += sx * h * h;
    p.z += sz * h * h;
    p.y *= 1.0 + 0.07 * sin(t * 4.6 + uPhase);
    gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
  }`;
```

Wait uHeight is cone height in object space, but the mesh scale.y pulses (1.07) — vH normalized to unscaled height — fine (shader sees unscaled position) ✓.

FLAME_FRAG:
```glsl
  uniform vec3 uColA;
  uniform vec3 uColB;
  uniform float uIntensity;
  varying float vH;
  void main(){
    vec3 col = mix(uColA, uColB, smoothstep(0.05, 0.72, vH));
    float a = pow(1.0 - smoothstep(0.28, 1.0, vH), 1.3);
    gl_FragColor = vec4(col * uIntensity, a * 0.9);
    #include <colorspace_fragment>
  }
```

Add flames:

```js
const flameGroup = new THREE.Group();
flameGroup.position.y = 0.14;
scene.add(flameGroup);
const flameMeshes = [];
function addFlame(r, h, phase, intensity, colA, colB){
  const geo = new THREE.ConeGeometry(r, h, 7, 4);
  geo.translate(0, h / 2, 0);
  const mesh = new THREE.Mesh(geo, flameMaterial(phase, h, intensity, colA, colB));
  mesh.userData.phase = phase;
  flameGroup.add(mesh);
  flameMeshes.push(mesh);
}
addFlame(0.8, 0.62, 1.3, 1.15, 0xffa040, 0xc62b00);
addFlame(0.56, 1.8, 0.0, 1.6, 0xffc266, 0xff4400);
addFlame(0.4, 1.35, 2.4, 1.85, 0xffd28a, 0xff5a0a);
addFlame(0.24, 1.0, 4.6, 2.5, 0xfff4c8, 0xffa020);
```

Great. Also note: these flame cones have flat tips (cone) ✓.

Scale animation on mesh — remember base at y=0 local, group at 0.14 ✓.

Also — I want flame billboard-ish look? No, 3D cones fine from all angles (DoubleSide) ✓.

**Glow:**

```js
const glowTex = radialTex([[0, 'rgba(255,196,120,0.85)'], [0.22, 'rgba(255,150,55,0.34)'], [0.5, 'rgba(255,110,30,0.10)'], [1, 'rgba(255,90,20,0)']]);
const glow = new THREE.Sprite(new THREE.SpriteMaterial({ map: glowTex, blending: THREE.AdditiveBlending, depthWrite: false, transparent: true, opacity: 0.3, fog: false }));
glow.position.set(0, 1.0, 0);
glow.scale.set(5.7, 5.7, 1);
scene.add(glow);
```

Now assemble everything. Also `renderer.domElement` style — default inline; body has canvas display block ✓.

Let me now also think about the **z-index and pointer events**: canvas appended last (after fixed overlays) — canvas is position static → fixed overlays with z-index 3+ render above ✓; canvas receives pointer events since overlays pointer-events:none ✓ (vignette, brand, hint none; loading has pointer events but removed after first frame; while loading covers, user can't interact — brief).

Hmm — `#loading` z-index 10 with background opaque: blocks first glimpse ~0.3s only ✓.

Actually to be safe for the 3s screenshot: module load + first frame should be < 1.5s on decent network. OK.

**One more idea — subtle animated star satellite? no.**

**Write final code.** Also ensure the fire group children: charred pit, ring rocks, teepee, embers, flames, glow, sparks, fireLight all near origin ✓ (added to scene individually).

**Sparks Points object:** `const sparks = new THREE.Points(sparkGeo, sparkMat); sparks.frustumCulled = false; scene.add(sparks);` — position default origin; shader computes p relative to origin ✓ fire at origin ✓.

**Fireflies Points:** `flies.frustumCulled = false;` positions real but shader moves ±2 → set false ✓.

**Stars:** `stars.frustumCulled = false;` cheap safety ✓.

Final code writing — also remember `renderer.setPixelRatio` before `setSize`? Either; onResize handles.

Let me also compute uniScale in onResize BEFORE first render: call onResize() at init ✓.

Also — for pixel ratio changes (browser zoom) handled by resize event mostly ✓.

Now — one careful detail: **`#include <colorspace_fragment>`** — correct chunk name in r170 ✓ (renamed from `encodings_fragment` in r152; r170 uses `colorspace_fragment`) ✓. `#include <tonemapping_fragment>` ✓ exists.

**Double-check ShaderMaterial + `attribute vec4 aSeed;`** — BufferAttribute itemSize 4 ✓.

**Check `fract(aSeed * 7.13)`** in spark vertex: aSeed is attribute float ✓.

Everything seems consistent. Now also think about **whether the point light's shadow far=60 & distance=60** — cutoff ✓.

**Do I need `fireLight.shadow.camera.updateProjectionMatrix()`?** Setting near/far before first render — the shadow camera's projection is updated internally on first use? For PointLight shadow, WebGLShadowMap updates matrices each frame; near/far read dynamically ✓ no manual update needed. For DirectionalLight ortho — shadow.camera left/right etc: need `light.shadow.camera.updateProjectionMatrix()` after changing? The shadow map render calls updateMatrices which uses camera.projectionMatrix — constructed at light creation with defaults; changing properties requires updateProjectionMatrix call. Common practice: call it. I'll call `moonLight.shadow.camera.updateProjectionMatrix();` ✓.

**`moonLight.shadow.mapSize.set(2048, 2048)` ✓.**

Now write everything out. Let me produce the final document carefully, mentally compiling as I go.

Also small correction — `THREE.MathUtils.smoothstep(d, 2.5, 12)` inside groundHeight gets called with d possibly 0 → 0 ✓.

Also grass at radius 2.8 min — bench logs at 2.75-2.9 might poke through — visually fine (grass tufts).

Bench logs y: groundHeight(x,z) + rad*0.75 → ground contact ✓.

Firewood pile y: groundHeight at (2.35,-3.5) ✓ sticks at local y 0.08 radius 0.07-0.09 ✓ approx contact ✓.

Tent sink -0.04 ✓.

Ring stones at y = s*0.4 (s≈0.2 → 0.08) — half-buried look ✓ ground 0 ✓.

Embers y 0.05+s*0.4 ✓.

Pit circle radius 1.02 slightly inside ring stones (r≈1.08±0.09 → inner edge 0.99) — pit edge just under stones ✓ nice.

Scattered rocks y = ground + s*0.45 ✓ sink ok.

**Boulders cast long moon shadows ✓.**

I think we're ready. Also, let me preemptively double-check the **OrbitControls 'start' event name** ✓ ('start', 'change', 'end').

**autoRotate continues until user grabs — even during damping after? 'start' fires on pointerdown interaction ✓.**

Also for **wheel** — 'start' fires for zoom interactions too? OrbitControls dispatches 'start' on wheel? Yes: handleMouseWheel dispatches start/end events (in recent versions onMouseWheel dispatches startEvent then endEvent) ✓.

Now, writing the final HTML. I'll be methodical. Also ensure closing braces match.

Let me also decide exposure 1.25 → maybe 1.15 safer against blowout near fire? Ground near fire at 37 lux → ACES(37 * albedo 0.2 / π? hmm physical: outgoing radiance = albedo/π * irradiance ≈ 0.2/3.14*37 ≈ 2.36 → ACES(2.36*1.25) ≈ ~0.93 bright but not clipped white ✓ warm glow good.

Stars additive raw: aColor ~0.5-1.0 * alpha ✓ visible on dark sky ✓.

Moon basic color 0xd7e4f6 → linear ~ (0.66,0.77,0.92) tonemapped ≈ 0.75 bright pale ✓.

OK writing.

Also — remember to set `sky.frustumCulled = false`? Camera inside sphere; bounding sphere radius 700 centered origin — always intersects ✓ leave.

Also `renderer.outputColorSpace` default srgb ✓.

Also add `document.title` fine.

Writing final code now, carefully, section by section, and then a quick mental pass for syntax errors.

Additionally — for a pinch of life: make the fire light color shift subtly (orange ↔ amber): `fireLight.color.setHSL(0.065 + 0.012*Math.sin(t*3.3), 0.85, 0.55)`? HSL 0.065*360 ≈ 23° orange ✓ subtle shifts. Hmm setHSL each frame ok perf. Include: hue = 0.055 + 0.01*sin(t*2.1)+0.006*fl? Keep simple: skip (avoid color weirdness). Just intensity + position flicker.

Also **flame tips & sparks visible against dark trees** ✓.

Final structure of module script:

1. imports
2. constants/helpers (rand, groundHeight, radialTex)
3. renderer, scene, camera, controls
4. lights
5. sky, moon, halo
6. stars
7. ground
8. rocks (scattered + ring)
9. logs (fallen + benches) + firewood
10. tent
11. grass
12. trees
13. fire pit + teepee + embers
14. flames
15. glow
16. sparks
17. fireflies
18. smoke
19. resize
20. animate loop
21. interaction hint

Let me write it.

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

const rand = (a, b) => a + Math.random() * (b - a);

function groundHeight(x, z){
  const d = Math.hypot(x, z);
  const flat = THREE.MathUtils.smoothstep(d, 2.5, 12);
  const h = Math.sin(x * 0.16) * Math.cos(z * 0.19) * 0.9
          + Math.sin(x * 0.42 + 1.7) * Math.cos(z * 0.35 + 0.6) * 0.42
          + Math.sin(x * 0.85 + 0.3) * Math.sin(z * 0.78 + 1.1) * 0.18;
  return h * flat;
}

function radialTex(stops, size = 256){
  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 [o, c] of stops) g.addColorStop(o, c);
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, size, size);
  const tex = new THREE.CanvasTexture(cv);
  tex.colorSpace = THREE.SRGBColorSpace;
  return tex;
}
```

then renderer etc.

Also: uniScale — define `const uniTime = { value: 0 }; const uniScale = { value: 1 }; const uniPR = { value: 1 };`

OK. And in onResize set uniPR & uniScale.

Also one more: I will add `const FOG_NEAR = 20, FOG_FAR = 82;`.

Let me write everything and count carefully the shader strings.

STAR_VERT:
```glsl
attribute vec3 aColor;
attribute float aSize;
attribute float aPhase;
attribute float aTw;
uniform float uTime;
uniform float uPR;
varying vec3 vColor;
varying float vAlpha;
void main(){
  vColor = aColor;
  float tw = 0.5 + 0.5 * sin(uTime * aTw + aPhase);
  vAlpha = mix(1.0, 0.35 + 0.65 * tw, 0.85);
  vec4 mv = modelViewMatrix * vec4(position, 1.0);
  gl_PointSize = aSize * uPR * (0.8 + 0.35 * tw);
  gl_Position = projectionMatrix * mv;
}
```

STAR_FRAG:
```glsl
varying vec3 vColor;
varying float vAlpha;
void main(){
  vec2 uv = gl_PointCoord - 0.5;
  float d = length(uv) * 2.0;
  float a = (1.0 - smoothstep(0.15, 1.0, d)) * vAlpha;
  if (a < 0.02) discard;
  gl_FragColor = vec4(vColor, a);
  #include <colorspace_fragment>
}
```

SPARK_VERT / FRAG as above (fix smoothstep direction).
FLY_VERT / FRAG as above (fix).
FLAME as above.
SKY as above.

Now the full script. I'm going to write it end-to-end.

Also — subtle: `smoothstep(0.0, 0.06, vLife)` spark fade-in ✓ GLSL fine.

And in spark frag: `a *= smoothstep(0.0, 0.05, vLife) * (1.0 - smoothstep(0.55, 1.0, vLife));` ✓.

Let me also double check smoke life modulo: `(t * u.speed + u.seed) % 1` — JS % on floats ✓.

Also `s.material.rotation += dt * u.spin;` ✓ SpriteMaterial.rotation ✓.

Finally, guard `im.instanceColor` update loops.

Also `grass.instanceColor.needsUpdate = true;` after loop — instanceColor exists because setColorAt called ✓ (only if COUNT>0 ✓).

One more consideration on **InstancedMesh count with pines 0** — ignore.

Also — **`spots` min-distance loop uses 7.3 (2.7²)** ✓.

Also note: bench/fallen logs vs scattered rocks may overlap randomly — visually fine.

Also ensure **no tree on tent** — trees ≥ 20, tent 7.8 ✓.

Now — write answer. Also keep an eye on total length; it'll be long (~700 lines) but that's fine.

Wait, one more requirement check: "Load Three.js only through this import map (do not use any other URL or version)" — I must include the importmap script exactly with those URLs, and only import from 'three' + 'three/addons/...' ✓.

Also the instructions say games need demo mode etc. — not a game; OrbitControls + autoRotate gives motion without interaction ✓ good for judging.

Let me write the resize + init:

```js
function onResize(){
  const w = innerWidth, h = innerHeight;
  camera.aspect = w / h;
  camera.updateProjectionMatrix();
  renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
  renderer.setSize(w, h);
  const pr = renderer.getPixelRatio();
  uniPR.value = pr;
  uniScale.value = (h * pr) / (2 * Math.tan(THREE.MathUtils.degToRad(camera.fov * 0.5)));
}
addEventListener('resize', onResize);
onResize();
```

degToRad(27.5) ✓.

Animation:

```js
const clock = new THREE.Clock();
let started = false;
renderer.setAnimationLoop(() => {
  const dt = Math.min(clock.getDelta(), 0.05);
  const t = clock.elapsedTime;
  uniTime.value = t;
  ... flicker etc ...
  controls.update();
  renderer.render(scene, camera);
  if (!started){
    started = true;
    loadingEl.style.opacity = '0';
    setTimeout(() => loadingEl.remove(), 800);
  }
});
```

`loadingEl` const defined before.

Interaction:

```js
let touched = false;
controls.addEventListener('start', () => {
  controls.autoRotate = false;
  if (!touched){ touched = true; hintEl.classList.add('hide'); }
});
```

Alright — final write-up. Let me also sanity check the tent ShapeGeometry dark opening — `tent.add(open)` — open.position z 1.53 while tent geometry spans z -1.5..1.5 → opening floats 0.03 in front ✓.

And tent extrude — `depth: 3.0` — extrudes 0..3 then translate -1.5 ✓.

Also `tent.rotation.y = Math.atan2(-tx, -tz)` — for (3.8, -6.8): atan2(-3.8, 6.8) = -0.510 ✓.

Done thinking — write the file.

Actually, hmm, let me reconsider one thing about the **flame additive over tone-mapped pipeline**: The flame fragment multiplies col (linear colors from hex) by uIntensity 1.15-2.5, alpha ~0.9 → additive contributions up to ~2.2 clamped at 1 after blending with background? Additive blending: dst + src*alpha, final framebuffer clamp 1 → center flames clip to white-ish yellow ✓ desired "hot" look.

But careful: `#include <colorspace_fragment>` converts linear→sRGB per-fragment BEFORE additive summation in framebuffer — slight hue shift vs post-converted; acceptable (common practice).

Also spark/star conversions similar ✓.

One last worry: **flames at 0.14 + cone scale** — group y 0.14: base flames start just above embers ✓.

And **the pit circle at y 0.012 vs ground y 0 near center** — ground height near center is exactly 0 (flat) ✓ no z-fight (0.012 gap at ~15m view distance: depth buffer distinguishable? Precision at 16m with near 0.1: dz resolution ≈ z²/(near * 2^24)?? ≈ 256/(0.1*16.7M) ≈ 0.00015 ✓ 0.012 >> fine ✓ no z-fight.

Similarly opening 0.03 ✓.

Now — actually also confirm ConeGeometry(0.8, 0.62, 7, 4) heightSegments param position: ConeGeometry(radius, height, radialSegments, heightSegments, openEnded) ✓.

CylinderGeometry(rTop, rBottom, height, radialSegments, heightSegments, openEnded) ✓ I use (rad*0.9, rad, len, 7) ✓ top slightly narrower ✓.

IcosahedronGeometry(size, detail) ✓.

DodecahedronGeometry(radius, detail) ✓.

CircleGeometry(radius, segments) ✓.

PlaneGeometry(w, h, wSeg, hSeg) ✓.

All good. Compose the final answer now.

For robustness, let me mentally recheck the sky shader compile: uniforms declared: uHorizon, uZenith, uFloor, uMoonDir, uMoonTint ✓ all provided. vDir varying ✓. `pow(clamp(d.y,0.0,1.0), 0.45)` ✓. gl_FragColor then includes ✓.

Star: uniforms uTime, uPR ✓ provided (uTime: uniTime, uPR: uniPR) ✓ attributes ✓.

Spark: uniforms uTime, uScale ✓ attributes aSeed, aSpeed, aAngle ✓ position attribute present (zeros) ✓ — but wait: I never reference `position` in spark vertex — three's WebGLProgram requires attribute 'position' in geometry for draw range ✓ present. GLSL compiler may warn unused but fine.

Fly: uses position + aSeed ✓ uniforms ✓.

Flame: uniforms uTime uPhase uHeight uIntensity uColA uColB ✓ declared in shaders: vertex declares uTime, uPhase, uHeight ✓; fragment declares uColA, uColB, uIntensity ✓.

Smoke: sprites standard ✓.

Also make sure **no duplicate uniform declaration between vertex/fragment** — separate scopes ✓.

Alright, final answer time. I'll write the complete file in one code block.

Let me estimate line counts as I go and keep formatting tidy.

One more mini-decision: initial `camera.position.set(8.5, 3.0, 14.0)` — distance 16.5; good.

Also set `controls.target.set(0, 1.1, 0)` ✓.

Also `scene.add(camera)` not needed.

Write it!

Also — I just realized: **autoRotate + damping: controls.update() needed ✓**; also autoRotate rotates even without user interaction from the start ✓ lovely motion for the 3s screenshot (slightly different azimuth — composition still good: rotating azimuth by ~0.6°/s*? autoRotateSpeed -0.6 → 0.6*... rate: 2.0 = 30s/orbit → 12°/s; 0.6 → 3.6°/s; in 3s ~11° azimuth shift — moon at 10° horizontal offset might drift toward edge... At 3s the screenshot azimuth shifted 11° → moon horizontal offset grows to ~13-20° — landscape half-FOV 40° ✓ still in frame. Mobile 16.3° half — borderline. Reduce autoRotateSpeed to -0.4 → 7° in 3s → mobile ok-ish (17° < ... hmm at 3.6°/s: 3s → 10.8° + base 10.6 = 21.4° > 16.3 → moon off-screen edge on mobile portrait at screenshot moment... but mobile portrait screenshot judging? The test viewport 1280×800. "adapt gracefully to 375" just needs no breakage. Moon drifting off-frame horizontally on narrow screens is acceptable "graceful". Still, let me lower base horizontal offset by choosing moon azimuth closer to initial view axis: moonDir (-0.30, 0.30, -0.92)? horizontal (-0.30, -0.92) vs facing (-0.51,-0.83)... angle: cos = (0.153+0.764)/(0.968*0.972)=0.917/0.941=0.975 → 12.8°?? Hmm that increased. Try moonDir (-0.45, 0.30, -0.84): horiz (-0.45,-0.84) |h|=0.953; cos with f_h (-0.52,-0.83)|0.98|: (0.234+0.697)/(0.934)=0.998 → ~3.6°! Elevation: 0.30/|v| |v|=sqrt(0.2025+0.09+0.7056)=sqrt(0.998)≈1 → el ≈ 17.5° ✓. So moonDir = (-0.45, 0.30, -0.84).normalize() → base horizontal offset ~3.6° ✓ moon near center-top even after autoRotate drift & on mobile ✓. 

Then moonlight comes from azimuth ~ matching view axis → moon shadows fall toward camera-ish (trees shadows point at viewer) — fine.

Update moonDir accordingly: new THREE.Vector3(-0.45, 0.30, -0.84).normalize() → magnitude sqrt(0.2025+0.09+0.7056)=sqrt(0.9981)=0.999 → ≈ (-0.4504, 0.3002, -0.8408).

Sky halo at that dir ✓.

Camera initial — also verify moon vertical: apparent elevation ≈ 17.5+6.9 ≈ 24.4° < 27.5 ✓ (near top, nice). With slight autoRotate drift vertical unchanged ✓.

OK now truly write.

Also — with autoRotateSpeed -0.4: 2.4°/s? autoRotateSpeed units: rotate 2PI per (30 / (speed/2))?? Docs: "rotateLeft(2 * PI / fps / 60 * autoRotateSpeed...)" Actually: rotateLeft(getAutoRotationAngle()) where angle = 2π/60/60*speed = speed*π/1800 rad/frame ≈ per frame at 60fps: 0.000349*speed*60 = 0.021*speed rad/s → speed 2 → 0.042 rad/s?? That gives 150s/orbit at speed 2?? Let me recompute: getAutoRotationAngle = 2π / 60 / 60 * autoRotateSpeed (per frame assuming 60fps) → per second: 2π/60 * speed = speed*6°/s. speed 2 → 12°/s → 30s/orbit ✓ matches docs (2.0 → 30s). So speed 0.4 → 2.4°/s → 3s screenshot → 7.2° drift → moon offset 3.6+7.2 = 10.8° < 16.3 mobile ✓. 

Use autoRotateSpeed = -0.4 (direction: negative → drifts moon offset to -3.6-... sign flips azimuth; either fine).

Good. Final write now — for real.

Ah also — one more check: initial camera azimuth vs moon azimuth both ~212° — moon roughly behind the scene center-top ✓.

Also the tent now: does it still sit right-of-screen? Tent offset dot right ≈ +6.1 computed before → right side ✓ (screen right), moon center-top ✓ benches lower center ✓ balanced.

Writing final code!

(One more trivial thing: `const S = 62;` etc. — just write literals.)

Also for the shadow camera near: light at distance ~90 from origin (moonDir*90 → |(-0.45,0.3,-0.84)|*90 = 90). Shadow camera near 20 far 220 ✓ covers ground plane extent (max ~106) ✓.

Also `moonLight.shadow.camera.updateProjectionMatrix()` ✓.

Final file below.