GLM 5.3
by Z.aiReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 2 min 22 s
- Cost
- $0.084
- Output tokens
- 18,891
- Spent thinking
- 12,706 tokens
- Speed
- 133.6 tok/s
- Console errors
- 0
- First token after
- 470 ms
- First visible text after
- 1 min 55 s
- Prompt tokens
- 559
- Code size
- 17.6 KB
- Finish reason
- stop
- Served by
- Together
- Model id
z-ai/glm-5.3- Reasoning sent
{"effort":"high"}- 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 · 457 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Midnight Clearing — Low-Poly Campfire</title>
<style>
html, body { margin:0; height:100%; overflow:hidden; background:#070b12; }
canvas { display:block; }
#ui { position:fixed; inset:0; pointer-events:none; font-family:system-ui, -apple-system, "Segoe UI", sans-serif; }
#title { position:absolute; top:22px; left:26px; color:#e8dcc3; }
#title h1 { margin:0; font-size:15px; font-weight:600; letter-spacing:.34em; text-transform:uppercase; text-shadow:0 2px 10px rgba(0,0,0,.9); }
#title p { margin:5px 0 0; font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:#8a94a8; text-shadow:0 1px 6px rgba(0,0,0,.9); }
#hint { position:absolute; bottom:20px; left:50%; transform:translateX(-50%); font-size:11px; letter-spacing:.24em; text-transform:uppercase; color:#c9b891; opacity:.85; text-shadow:0 1px 8px rgba(0,0,0,.95); transition:opacity 1.2s ease; white-space:nowrap; }
#vignette { position:absolute; inset:0; background:radial-gradient(ellipse at 50% 42%, transparent 52%, rgba(2,4,8,.55) 100%); }
.faded { opacity:0 !important; }
@media (max-width: 560px){ #title{ left:16px; top:16px; } }
</style>
<script type="importmap">
{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"}}
</script>
</head>
<body>
<div id="ui">
<div id="title"><h1>Midnight Clearing</h1><p>low-poly campfire · three.js</p></div>
<div id="vignette"></div>
<div id="hint">drag to orbit · scroll to zoom</div>
</div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
/* ---------------- renderer / scene / camera ---------------- */
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const NIGHT = new THREE.Color(0x070b12);
scene.background = NIGHT;
scene.fog = new THREE.FogExp2(0x070b12, 0.03);
const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 600);
camera.position.set(8.5, 4.6, 11.5);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1.3, 0);
controls.enableDamping = true;
controls.dampingFactor = 0.06;
controls.minDistance = 4;
controls.maxDistance = 26;
controls.minPolarAngle = 0.15;
controls.maxPolarAngle = Math.PI / 2 - 0.07; // never dips below the ground
controls.enablePan = false;
controls.autoRotate = true; // gentle drift until the user grabs it
controls.autoRotateSpeed = 0.5;
controls.update();
/* ---------------- lights ---------------- */
scene.add(new THREE.HemisphereLight(0x22334d, 0x0e1410, 0.55));
const moonLight = new THREE.DirectionalLight(0x8fa5cc, 0.45);
moonLight.position.set(70, 60, -95);
scene.add(moonLight);
const fireLight = new THREE.PointLight(0xff8c33, 55, 30, 2);
fireLight.position.set(0, 1.0, 0);
fireLight.castShadow = true;
fireLight.shadow.mapSize.set(1024, 1024);
fireLight.shadow.camera.near = 0.3;
fireLight.shadow.camera.far = 30;
fireLight.shadow.bias = -0.005;
fireLight.shadow.normalBias = 0.02;
scene.add(fireLight);
/* ---------------- ground (shared height function so props sit on it) ---------------- */
function groundH(x, z) {
const d = Math.hypot(x, z);
if (d <= 4.5) return 0;
const f = Math.min(1, (d - 4.5) / 7);
const w = f * f;
return (Math.sin(x * 0.32) * Math.cos(z * 0.28) * 0.4 +
Math.sin(x * 0.11 + z * 0.17) * 0.9 +
Math.sin(x * 0.07 - z * 0.09) * 0.55) * w;
}
const gGeo = new THREE.PlaneGeometry(130, 130, 46, 46);
gGeo.rotateX(-Math.PI / 2);
const gp = gGeo.attributes.position;
const gColors = new Float32Array(gp.count * 3);
const cGrass = new THREE.Color(0x24371f);
const cGrass2 = new THREE.Color(0x2b3d22);
const cDirt = new THREE.Color(0x3a2c1c);
const cChar = new THREE.Color(0x191410);
const tmpC = new THREE.Color();
for (let i = 0; i < gp.count; i++) {
const x = gp.getX(i), z = gp.getZ(i);
const d = Math.hypot(x, z);
gp.setY(i, groundH(x, z));
tmpC.copy(cGrass).lerp(cGrass2, Math.random());
if (d < 6) tmpC.lerp(cDirt, Math.min(1, (1 - Math.max(0, d - 2.2) / 3.8) * 1.4));
if (d < 2.6) tmpC.lerp(cChar, 0.8);
gColors[i * 3] = tmpC.r; gColors[i * 3 + 1] = tmpC.g; gColors[i * 3 + 2] = tmpC.b;
}
gGeo.setAttribute('color', new THREE.BufferAttribute(gColors, 3));
gGeo.computeVertexNormals();
const ground = new THREE.Mesh(gGeo, new THREE.MeshStandardMaterial({
vertexColors: true, flatShading: true, roughness: 1, metalness: 0
}));
ground.receiveShadow = true;
scene.add(ground);
/* ---------------- rocks ---------------- */
const rockGeo = new THREE.IcosahedronGeometry(1, 0);
const rockMat = new THREE.MeshStandardMaterial({ color: 0x6a6d70, flatShading: true, roughness: 0.95 });
function addRock(x, z, s, squash) {
const r = new THREE.Mesh(rockGeo, rockMat);
r.scale.set(s, s * squash, s * (0.75 + Math.random() * 0.5));
r.rotation.set(Math.random() * 0.6, Math.random() * Math.PI * 2, Math.random() * 0.6);
r.position.set(x, groundH(x, z) + s * squash * 0.35, z);
r.castShadow = r.receiveShadow = true;
scene.add(r);
}
// stone ring around the fire
for (let i = 0; i < 9; i++) {
const a = (i / 9) * Math.PI * 2 + Math.random() * 0.3;
addRock(Math.cos(a) * 1.55, Math.sin(a) * 1.55, 0.16 + Math.random() * 0.1, 0.65);
}
// scattered boulders in the clearing
for (let i = 0; i < 11; i++) {
const a = Math.random() * Math.PI * 2;
const d = 2.8 + Math.random() * 6.5;
addRock(Math.cos(a) * d, Math.sin(a) * d, 0.25 + Math.random() * 0.45, 0.55 + Math.random() * 0.25);
}
/* ---------------- logs ---------------- */
const logGeo = new THREE.CylinderGeometry(0.09, 0.13, 1.6, 6);
logGeo.translate(0, 0.8, 0); // pivot at the thick base
const charLogMat = new THREE.MeshStandardMaterial({
color: 0x3a2a1c, flatShading: true, roughness: 1,
emissive: 0x992a05, emissiveIntensity: 0.35
});
const N_LOGS = 6;
for (let i = 0; i < N_LOGS; i++) {
const a = (i / N_LOGS) * Math.PI * 2 + Math.random() * 0.25;
const pivot = new THREE.Group();
pivot.position.set(Math.cos(a) * 0.9, 0.02, Math.sin(a) * 0.9);
pivot.rotation.y = -a; // local -X now faces the fire
const log = new THREE.Mesh(logGeo, charLogMat);
log.rotation.z = 0.52 + Math.random() * 0.12; // lean toward the flames
log.castShadow = true;
pivot.add(log);
scene.add(pivot);
}
// two fallen logs to sit on
const benchMat = new THREE.MeshStandardMaterial({ color: 0x4a3323, flatShading: true, roughness: 1 });
const benchGeo = new THREE.CylinderGeometry(0.18, 0.2, 2.4, 7);
[[2.2, 3.1, 0.4], [-3.4, -1.6, 1.7]].forEach(([x, z, ry]) => {
const b = new THREE.Mesh(benchGeo, benchMat);
b.rotation.z = Math.PI / 2;
b.rotation.y = ry;
b.position.set(x, groundH(x, z) + 0.18, z);
b.castShadow = b.receiveShadow = true;
scene.add(b);
});
// small chopped-wood pile
const splitGeo = new THREE.CylinderGeometry(0.13, 0.13, 0.55, 6);
const pile = new THREE.Group();
pile.position.set(2.7, groundH(2.7, 1.9), 1.9);
[[-0.16, 0.13, 0.1], [0.16, 0.13, -0.12], [0, 0.38, 0.02]].forEach(([x, y, z], i) => {
const s = new THREE.Mesh(splitGeo, benchMat);
s.rotation.set(Math.PI / 2, 0, i * 0.6);
s.position.set(x, y, z);
s.castShadow = true;
pile.add(s);
});
scene.add(pile);
/* ---------------- grass tufts (instanced) ---------------- */
const gGrass = new THREE.ConeGeometry(0.055, 0.4, 4);
gGrass.translate(0, 0.2, 0);
const grass = new THREE.InstancedMesh(gGrass,
new THREE.MeshStandardMaterial({ color: 0x2c4526, flatShading: true, roughness: 1 }), 150);
const dummy = new THREE.Object3D();
for (let i = 0; i < 150; i++) {
const a = Math.random() * Math.PI * 2;
const d = 2.6 + Math.random() * 8.5;
dummy.position.set(Math.cos(a) * d, groundH(Math.cos(a) * d, Math.sin(a) * d), Math.sin(a) * d);
dummy.rotation.set((Math.random() - 0.5) * 0.5, Math.random() * Math.PI, (Math.random() - 0.5) * 0.5);
dummy.scale.setScalar(0.7 + Math.random());
dummy.updateMatrix();
grass.setMatrixAt(i, dummy.matrix);
}
grass.receiveShadow = true;
scene.add(grass);
/* ---------------- trees (38, kept clear of the fire) ---------------- */
const trunkMat = new THREE.MeshStandardMaterial({ color: 0x4a3323, flatShading: true, roughness: 1 });
const leafMats = [0x1b3a22, 0x21452a, 0x183521, 0x25502e].map(c =>
new THREE.MeshStandardMaterial({ color: c, flatShading: true, roughness: 1 }));
function makeTree() {
const g = new THREE.Group();
const s = 0.85 + Math.random() * 0.95;
const trunkH = 1.1 * s;
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.1 * s, 0.16 * s, trunkH, 5), trunkMat);
trunk.position.y = trunkH / 2;
trunk.castShadow = true;
g.add(trunk);
const mat = leafMats[(Math.random() * leafMats.length) | 0];
let y = trunkH * 0.75, r = (0.85 + Math.random() * 0.3) * s;
for (let i = 0; i < 3; i++) {
const h = (1.3 - i * 0.25) * s;
const cone = new THREE.Mesh(new THREE.ConeGeometry(r, h, 6), mat);
cone.position.y = y + h / 2 - 0.05;
cone.rotation.y = Math.random() * Math.PI;
cone.castShadow = true;
g.add(cone);
y += h * 0.55;
r *= 0.72;
}
return g;
}
const treeSpots = [];
let placed = 0, guard = 0;
while (placed < 38 && guard++ < 1200) {
const a = Math.random() * Math.PI * 2;
const d = 10.5 + Math.random() * 22;
const x = Math.cos(a) * d, z = Math.sin(a) * d;
let ok = true;
for (const p of treeSpots) {
const dx = p.x - x, dz = p.z - z;
if (dx * dx + dz * dz < 6.5) { ok = false; break; }
}
if (!ok) continue;
treeSpots.push({ x, z });
const t = makeTree();
t.position.set(x, groundH(x, z) - 0.05, z);
t.rotation.y = Math.random() * Math.PI * 2;
scene.add(t);
placed++;
}
/* ---------------- campfire: flames ---------------- */
const flames = [];
function makeFlame(radius, height, color, opacity, speed) {
const geo = new THREE.ConeGeometry(radius, height, 6, 4);
geo.translate(0, height / 2, 0);
const mesh = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({
color, transparent: true, opacity,
blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide
}));
mesh.userData = {
base: geo.attributes.position.array.slice(),
height, speed, phase: Math.random() * Math.PI * 2, spin: (Math.random() - 0.5) * 2
};
flames.push(mesh);
return mesh;
}
const fireGroup = new THREE.Group();
fireGroup.position.y = 0.08;
fireGroup.add(makeFlame(0.55, 1.7, 0xff4d00, 0.5, 5.2));
fireGroup.add(makeFlame(0.36, 1.25, 0xff8c1a, 0.65, 6.8));
fireGroup.add(makeFlame(0.20, 0.9, 0xffd257, 0.85, 8.5));
scene.add(fireGroup);
// hot embers between the logs
const emberMat = new THREE.MeshStandardMaterial({
color: 0x1a0d06, emissive: 0xff5a08, emissiveIntensity: 2, flatShading: true, roughness: 1
});
for (let i = 0; i < 6; i++) {
const a = Math.random() * Math.PI * 2;
const d = Math.random() * 0.5;
const e = new THREE.Mesh(new THREE.IcosahedronGeometry(0.07 + Math.random() * 0.05, 0), emberMat);
e.position.set(Math.cos(a) * d, 0.07 + Math.random() * 0.1, Math.sin(a) * d);
e.rotation.set(Math.random() * 3, Math.random() * 3, Math.random() * 3);
scene.add(e);
}
// soft glow pool on the ground
const glowDisc = new THREE.Mesh(
new THREE.CircleGeometry(2.0, 20),
new THREE.MeshBasicMaterial({
color: 0xff6a1f, transparent: true, opacity: 0.3,
blending: THREE.AdditiveBlending, depthWrite: false
})
);
glowDisc.rotation.x = -Math.PI / 2;
glowDisc.position.y = 0.045;
scene.add(glowDisc);
/* ---------------- sparks ---------------- */
const SPARKS = 140;
const sPos = new Float32Array(SPARKS * 3);
const sCol = new Float32Array(SPARKS * 3);
const sData = new Array(SPARKS);
function resetSpark(i, initial) {
const d = sData[i];
d.maxLife = 1.4 + Math.random() * 1.6;
d.life = initial ? Math.random() * d.maxLife : d.maxLife;
d.x = (Math.random() - 0.5) * 0.5;
d.y = 0.5 + Math.random() * 0.7;
d.z = (Math.random() - 0.5) * 0.5;
d.vx = (Math.random() - 0.5) * 0.4;
d.vy = 0.9 + Math.random() * 1.5;
d.vz = (Math.random() - 0.5) * 0.4;
d.seed = Math.random() * 100;
}
for (let i = 0; i < SPARKS; i++) { sData[i] = {}; resetSpark(i, true); }
const sGeo = new THREE.BufferGeometry();
sGeo.setAttribute('position', new THREE.BufferAttribute(sPos, 3));
sGeo.setAttribute('color', new THREE.BufferAttribute(sCol, 3));
const sparkPoints = new THREE.Points(sGeo, new THREE.PointsMaterial({
size: 0.09, sizeAttenuation: true, vertexColors: true,
transparent: true, blending: THREE.AdditiveBlending, depthWrite: false
}));
sparkPoints.frustumCulled = false;
scene.add(sparkPoints);
/* ---------------- night sky: stars + moon ---------------- */
function makeStars(n, size, minBright) {
const pos = new Float32Array(n * 3), col = new Float32Array(n * 3);
let i = 0;
while (i < n) {
let x = Math.random() * 2 - 1, y = Math.random(), z = Math.random() * 2 - 1;
const l = Math.hypot(x, y, z);
if (l > 1 || l < 0.2 || y / l < 0.12) continue;
const R = 240;
pos[i * 3] = (x / l) * R; pos[i * 3 + 1] = (y / l) * R; pos[i * 3 + 2] = (z / l) * R;
const b = minBright + Math.random() * (1 - minBright);
const warm = Math.random() * 0.15;
col[i * 3] = b; col[i * 3 + 1] = b * (0.94 + warm * 0.06); col[i * 3 + 2] = b * (0.85 + warm * 0.15);
i++;
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
g.setAttribute('color', new THREE.BufferAttribute(col, 3));
const m = new THREE.PointsMaterial({
size, sizeAttenuation: false, vertexColors: true,
transparent: true, opacity: 1, fog: false, depthWrite: false
});
const p = new THREE.Points(g, m);
p.frustumCulled = false;
scene.add(p);
return m;
}
makeStars(360, 2.0, 0.35);
const brightStarMat = makeStars(60, 3.6, 0.75);
const moonPos = new THREE.Vector3(70, 60, -95);
const moon = new THREE.Mesh(
new THREE.SphereGeometry(6, 16, 12),
new THREE.MeshBasicMaterial({ color: 0xdfe6ee, fog: false })
);
moon.position.copy(moonPos);
scene.add(moon);
const halo = new THREE.Mesh(
new THREE.SphereGeometry(11, 16, 12),
new THREE.MeshBasicMaterial({
color: 0x9fb4d8, transparent: true, opacity: 0.12,
blending: THREE.AdditiveBlending, fog: false, depthWrite: false
})
);
halo.position.copy(moonPos);
scene.add(halo);
/* ---------------- interaction ---------------- */
function onInteract() {
controls.autoRotate = false;
document.getElementById('hint').classList.add('faded');
window.removeEventListener('pointerdown', onInteract);
window.removeEventListener('wheel', onInteract);
window.removeEventListener('keydown', onInteract);
}
window.addEventListener('pointerdown', onInteract);
window.addEventListener('wheel', onInteract);
window.addEventListener('keydown', onInteract);
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
/* ---------------- animation loop ---------------- */
const clock = new THREE.Clock();
let time = 0;
const SA = { r: 1.0, g: 0.72, b: 0.28 }; // spark born
const SB = { r: 0.55, g: 0.08, b: 0.01 }; // spark dying
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
time += dt;
// fire flicker (layered sines -> organic, not a blink)
const fl = 0.75 + 0.25 * (Math.sin(time * 11.0) * 0.45 +
Math.sin(time * 17.7 + 1.7) * 0.3 +
Math.sin(time * 29.3 + 4.2) * 0.25);
fireLight.intensity = 55 * fl;
fireLight.position.set(0.07 * Math.sin(time * 13.1), 1.0 + 0.06 * Math.sin(time * 9.7), 0.07 * Math.cos(time * 11.3));
glowDisc.material.opacity = 0.24 + 0.2 * fl;
emberMat.emissiveIntensity = 1.3 + 1.6 * fl;
// flames: low-poly vertex wobble + pulse + slow spin
for (const f of flames) {
const u = f.userData;
const t = time * u.speed + u.phase;
const pos = f.geometry.attributes.position;
const arr = pos.array, base = u.base;
for (let i = 0; i < pos.count; i++) {
const by = base[i * 3 + 1];
const w = (by / u.height) ** 2; // tips wobble most
arr[i * 3] = base[i * 3] + Math.sin(t + by * 3.1) * 0.13 * w;
arr[i * 3 + 2] = base[i * 3 + 2] + Math.cos(t * 1.13 + by * 2.7) * 0.13 * w;
arr[i * 3 + 1] = by * (1 + 0.07 * Math.sin(t * 1.7));
}
pos.needsUpdate = true;
const s = 1 + 0.1 * Math.sin(t * 1.9);
f.scale.set(s, 1 + 0.16 * Math.sin(t * 1.3 + 1), s);
f.rotation.y += dt * u.spin;
}
// sparks
for (let i = 0; i < SPARKS; i++) {
const d = sData[i];
d.life -= dt;
if (d.life <= 0 || d.y > 5.5) resetSpark(i, false);
d.x += (d.vx + Math.sin(time * 2.3 + d.seed) * 0.4) * dt;
d.z += (d.vz + Math.cos(time * 2.0 + d.seed) * 0.4) * dt;
d.y += d.vy * dt;
const f = Math.max(0, d.life / d.maxLife);
const dim = 0.25 + 0.75 * f;
sPos[i * 3] = d.x; sPos[i * 3 + 1] = d.y; sPos[i * 3 + 2] = d.z;
sCol[i * 3] = (SB.r + (SA.r - SB.r) * f) * dim;
sCol[i * 3 + 1] = (SB.g + (SA.g - SB.g) * f) * dim;
sCol[i * 3 + 2] = (SB.b + (SA.b - SB.b) * f) * dim;
}
sGeo.attributes.position.needsUpdate = true;
sGeo.attributes.color.needsUpdate = true;
// star twinkle
brightStarMat.opacity = 0.6 + 0.4 * (0.5 + 0.5 * Math.sin(time * 1.3));
controls.update();
renderer.render(scene, camera);
}
animate();
</script>
</body>
</html>Reasoning trace (12,706 tokens)
Let me build a complete Three.js campfire scene. Key elements:
1. Ground: circular plane with vertex displacement for low-poly look, flat shading.
2. Rocks: icosahedron geometry, flattened, around fire.
3. Logs: cylinders leaning into the fire teepee style.
4. Trees: 30+ cone + cylinder low-poly trees, random positions outside clearing radius, avoid fire.
5. Fire: animated flame meshes (cones with noise-based scale/color animation), point light flickering with shadows.
6. Sparks: Points particle system rising with turbulence.
7. Sky: dark background color, stars (Points on dome), moon (sphere or circle mesh with emissive), fog (FogExp2 or Fog).
8. OrbitControls with maxPolarAngle < PI/2 (stay above ground), min/max distance.
9. Resize handler.
10. Everything must show on first paint — no interaction needed. Maybe add a subtle "drag to look around" hint.
Let me write careful code. Use flat shading MeshStandardMaterial. Shadows: renderer.shadowMap.enabled, point light castShadow with shadow map size. PointLight shadows are cube maps — 6 renders; fine for performance with modest geometry.
Flames: create several cone geometries, animate scale, rotation, position with time-based sine noise. Use additive blending, transparent materials with colors from orange to yellow. Also MeshBasicMaterial for flames (not affected by lighting).
Fire glow: inner logs with emissive orange (embers).
Sparks: BufferGeometry with positions, velocities; update per frame, reset when above limit. Use PointsMaterial with additive blending, size attenuation, orange color, small size. Random life.
Stars: random points on upper hemisphere at large radius. PointsMaterial white, small.
Moon: a circle/sphere with MeshBasicMaterial pale, placed far away; maybe a soft glow sprite — keep simple: sphere with basic material plus slight emissive halo via larger transparent sphere.
Fog: scene.fog = new THREE.FogExp2(0x0a0e14, 0.028) or Fog with near/far. Background color matching fog.
Ground: PlaneGeometry with segments, random y displacement, but flat near center for fire. Use CircleGeometry with segments? Plane with segments rotated. Vertex colors to darken edges? Fog handles that. Add slight vertex color variation for grass tones.
Trees: cone(s) stacked for foliage — 2-3 cones, dark green, flat shaded. Trunk cylinder brown. Random scale and rotation. Place at radius between clearingRadius (~9) and 30. Ensure min distance from fire and from each other loosely (just radius check ok). Requirement: not on the fire — keep distance.
Stones ring around fire: 8-10 small rocks in a circle.
Camera: position (8, 5, 10) looking at fire (0, 1, 0). Controls target (0,1.2,0), maxPolarAngle = 1.45 (~83°) to stay above ground. minDistance 5, maxDistance 30. enableDamping.
Antialiasing, renderer pixel ratio capped at 2.
Ambient light: very dim blue (0x223344, intensity low) plus maybe hemisphere light dim. Moon directional light dim blue casting subtle shadow? Point light shadow is required for the fire; directional light shadow optional — could add faint moonlight without shadow for performance. Add a subtle blue directional light from moon direction, no shadows.
Flicker: point light intensity varies with noise (sum of sines) plus position jitter small. castShadow true, shadow.mapSize 1024, bias.
Flame animation: each flame cone: y-scale oscillation, x/z sway. Also color lerp. Use a few cones of different heights/colors, additive blending, depthWrite false.
Ember glow at base: small emissive sphere or circle.
Also add a "Click or press a key to start"? Not a game — it's a scene; no need. But maybe add hint text "Drag to orbit · Scroll to zoom" small overlay, fade it. That's fine and helps.
Heat distortion is complex — skip.
Let me write code.
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Night Campfire — Low Poly Forest</title>
<style>
html,body{margin:0;height:100%;overflow:hidden;background:#0a0e14;}
#hint { position:fixed; bottom:18px; left:50%; transform:translateX(-50%); color:#d8c9a8; font: 13px/1.4 system-ui, sans-serif; letter-spacing:.08em; text-transform:uppercase; opacity:.75; pointer-events:none; text-shadow:0 1px 4px rgba(0,0,0,.8); transition:opacity 1s; }
canvas{display:block}
</style>
<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"}}</script>
</head>
<body>
<div id="hint">drag to orbit · scroll to zoom</div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
...
</script>
</body>
</html>
```
Scene setup:
```js
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0e16);
scene.fog = new THREE.FogExp2(0x0a0e16, 0.03);
```
Camera, renderer with shadowMap PCFSoft.
Ground:
```js
const gGeo = new THREE.CircleGeometry(60, 48, ...);
```
Better: PlaneGeometry(120,120,40,40), rotate -PI/2, displace vertices: distance from center; if d < 6 keep flat-ish (small noise), else noise scaled. Use simple pseudo-noise: sin/cos combos + random. Random per-vertex gives harsh low-poly, that's fine with flat shading. But keep vertices deterministic-ish; random fine.
Vertex colors: mix dark green-brown; darker with distance? fog handles. Use MeshStandardMaterial vertexColors true, flatShading true, roughness 1.
Grass tufts? Optional — small cones of green scattered. Could add some low-poly grass blades (small thin cones) near clearing for detail. Keep count modest (~120).
Rocks: IcosahedronGeometry(size, 0), random scale, squashed y, gray material with flat shading. Place 10-14 random within clearing radius 4-8, avoiding fire radius 2. Also a stone ring around fire: 9 rocks at radius 1.6.
Logs: 5 cylinders (radiusTop 0.09, radiusBottom 0.13, length 1.4, 6 sides), leaning teepee: position around center radius ~0.7, rotated so top meets above center. Compute: place each log tilted toward center. Simple approach: for i in logs: angle = i * 2PI/5; log mesh, set position at (cos*0.75, 0.35, sin*0.75), rotate to lean: rotate around axis. Easiest: create log, translate geometry so it pivots at bottom, then rotate.z toward center. Or set rotation using lookAt. Alternative:
```js
const log = new THREE.Mesh(logGeo, logMat);
log.position.set(Math.cos(a)*0.8, 0, Math.sin(a)*0.8);
log.rotation.y = -a; // orient
log.rotation.z = ...
```
Hmm. Let me think: cylinder along Y by default. I want it leaning inward. Approach: create group at position on circle facing center; rotate group by -angle around Y so it faces center along -X? Let's do: group.position = (cos a * r, 0, sin a * r); group.lookAt(0, 0.0, 0) — lookAt points -Z toward target for Object3D? Object3D.lookAt orients +Z toward target for cameras... Actually Object3D.lookAt rotates so that its internal -Z? For non-camera objects, lookAt points the object's +Z... Let me recall: Object3D.lookAt makes the object's +Z axis point toward... no — it makes -Z for cameras; for other objects +Z points at target? The docs: "Rotates the object to face a point in world space." Implementation uses lookAt matrix same as camera, meaning -Z faces target; but for objects (non-camera), three.js uses the same matrix, so +Z faces away... Actually in three.js, Object3D.lookAt: if it's a camera or light, -Z faces target; otherwise +Z faces target. Yes, since r?some version, non-camera objects orient +Z toward target. Correct: in recent three.js, `lookAt` orients objects so +Z points at the target (cameras/lights get -Z).
So: group at (cos a * 0.85, 0.5, sin a * 0.85); group.lookAt(0, 1.1, 0) roughly pointing up-center; then inside group, log cylinder rotated to lean: rotate log around X by tilt angle... Simpler alternative: skip group, directly compute rotation via quaternion:
```js
function tiltedLog(angle){
const mesh = new THREE.Mesh(logGeo, logMat);
const r = 0.85, h = 0.5;
mesh.position.set(Math.cos(angle)*r, h, Math.sin(angle)*r);
// direction from base outward...
}
```
Simplest robust method: pivot geometry at bottom end: logGeo.translate(0, len/2, 0) so origin at base. Then position base at (cos a * 0.9, 0.05, sin a * 0.9). Then rotate mesh so it leans toward center by tilt (~0.5 rad). Rotation: first rotate around Y by -angle (align local X outward), then rotate around Z by +tilt (leaning toward -X? Let's see). After mesh.rotation.y = -a, the local axes: we want the log to lean toward the center. If the base is at angle a, the center direction is -(cos a, sin a) in XZ. Setting rotation order 'YXZ' or apply rotations: mesh.rotation.set(0, -a, tilt) with order 'YXZ'? Rotation order default 'XYZ' applies X then Y then Z in... three.js Euler 'XYZ' means rotation matrix = Rx * Ry * Rz? Actually in three.js, 'XYZ' order means the object is rotated about X, then Y, then Z in... hmm, the matrix is composed as R = RX(x) * RY(y) * RZ(z)? Three.js Euler XYZ: m = RotX * RotY * RotZ applied to vector => vector rotated by Z first, then Y, then X (intrinsic reversed). Ugh.
To avoid confusion, use group approach:
```js
const pivot = new THREE.Group();
pivot.position.set(Math.cos(a)*0.9, 0.02, Math.sin(a)*0.9);
pivot.rotation.y = -a; // now pivot's -X points toward center?
```
If pivot at angle a on circle, direction toward center in world is (-cos a, -sin a) in XZ. After rotation.y = -a, the world direction rotates... Let's just test conceptually: rotation.y = θ rotates around Y such that a vector (1,0,0) maps to (cos θ, 0, -sin θ). We want pivot's local -X (i.e., (-1,0,0)) to point toward center: (-cos a, 0, -sin a). Local (-1,0,0) maps to (-cos θ, 0, sin θ). Set -cos θ = -cos a and sin θ = -sin a → θ = -a. Yes! rotation.y = -a makes local -X point at center.
Then inside pivot, log with base-origin geometry along +Y, rotate around Z: rotation.z = φ rotates +Y toward... rotating around Z by +φ maps (0,1,0) to (-sin φ, cos φ, 0) — leans toward -X, i.e., toward center.
So log.rotation.z = 0.55 (lean toward center). Log length 1.6, base at 0.9 out, top ends near center above fire at height ~1.5. Good.
Plus 2 fallen logs lying flat away from fire, and a couple of log stumps / seating logs around fire (nice detail): 2-3 horizontal cylinders at radius 2.5-3.5 like benches.
Trees:
```js
function makeTree(scale){ group: trunk cylinder(0.12*s, 0.18*s, 1*s) brown; foliage 2-3 cones stacked dark greens with random hue variation }
```
Count: place 34 trees. radius between 10 and 34, random angle. Check min distance from fire 10 — radius handles. Slight clustering ok. Avoid overlapping heavily: naive check against existing positions, min dist 2.2, retry loop.
Tree materials: share a few materials for perf. Foliage colors: 0x1c3a24, 0x20402a, 0x17301f variations. Flat shading. castShadow = true on trunk and foliage (point light shadow, trees far away get shadows — fine). Actually shadow camera far distance for point light: light.shadow.camera.far = 25 default? PointLight shadow far default is... The shadow camera far defaults to 500? For PointLightShadow, camera near 0.5 far... it's a PerspectiveCamera with fov 90, near 0.5, far... I think default far is quite large. Trees within ~20 units will cast — good, gives flickering tree shadows. Set light.shadow.camera.far = 30 to limit.
Fire flames:
- Group at center.
- 3-4 cone meshes (ConeGeometry(1, 1, 6, 1) scaled), colors: deep orange 0xff5a00, orange 0xff8c1a, yellow 0xffc94d inner. MeshBasicMaterial transparent, opacity ~0.85, additive blending, depthWrite false. Animate: scale.y = base*(1 + 0.25*sin(t*freq + phase)) plus faster flicker using sin combos; position.x/z sway small; rotation.y += spin.
- Also a flat "glow" disc at ground: circle with additive orange, low opacity, radius 1.5.
Embers: small icosahedrons glowing between logs with emissive orange, flicker emissiveIntensity.
Flame geometry: cone with radialSegments 6, open? Use ConeGeometry(0.45, 1.2, 6, 3) maybe with vertex animation? Simpler: just scale/rotate. Could add slight vertex noise per frame on the flame cone for organic feel — but shared geometry; give each flame its own geometry clone and jitter vertices slightly per frame for low-poly wobble. That's a nice touch: modify position attribute each frame with sin-based offsets. 3 flames × ~ (6*3*... vertices) small. Let's do: geometry with heightSegments 4, per-frame vertex x/z offsets proportional to y (tip wobbles more). That makes nice low-poly flame wobble. Need geometry.computeVertexNormals? MeshBasicMaterial doesn't need normals. Set positions, mark needsUpdate.
Sparks:
```js
const SPARKS = 120;
positions Float32Array, data array {vel, life, maxLife, size?}
PointsMaterial size 0.08, color 0xffb84d, additive, transparent, depthWrite false, sizeAttenuation.
```
Update: pos += vel*dt; vel.y ~ 1.2-2.4; add swirl: x += sin(t*...+seed)*...; life decreases; when life<=0 or y>6, reset at fire base (random small radius, y 0.4-1). Fade via opacity? Per-point opacity needs custom shader or vertex colors. Use vertex colors and set color darkening with life: colors attribute updated each frame → spark dims as it rises. PointsMaterial vertexColors true. Color from bright yellow-orange to dark red as life ends. Good.
Stars: 400 points on sphere radius 200, only upper hemisphere (y > 5). Sizes vary — PointsMaterial single size 1.2 with sizeAttenuation false so they stay pixel-size. Add vertex colors slight variation white/blue-ish/warm. fog: PointsMaterial affected by fog — set material.fog = false for stars and moon so they don't fade.
Moon: SphereGeometry(6, 16, 16) MeshBasicMaterial 0xdfe6ee at position like (60, 55, -90), plus glow: larger sphere (9) with basic material additive transparent opacity 0.15? BackSide? Just a sprite-ish: use a Mesh with sphere and additive. Or a Points single large... Keep sphere + halo sphere, fog false. Maybe a dim crescent? Full moon fine. Add directional light from moon direction, color 0x8fa8c8, intensity 0.25, no shadow (or with shadow? point light already gives shadows; directional shadow adds another render pass — skip for perf, keep no shadow or castShadow false).
Fog density: with trees up to radius 34 and camera ~12 away, FogExp2 density 0.022 — at distance 30, factor exp(-(0.022*30)^2)= exp(-0.435)=0.65 visible... FogExp2 formula: exp(-(density*depth)^2). density 0.03, depth 30 → exp(-0.81)=0.44 — decent fade. Use 0.028.
Star field radius 200 with fog false. Background color: very dark blue 0x070b12.
Controls hint fade after interaction: on 'pointerdown' or 'wheel', fade hint opacity to 0.
Animation loop with clock, delta clamp.
Resize handler.
Also handle color space / tone mapping: renderer.toneMapping = ACESFilmic, exposure 1.1; outputColorSpace default SRGB in 0.170. Good for warm fire look.
Point light: color 0xff7a1e... warm: 0xff9040. Intensity: three.js 0.170 uses physically correct lights by default (useLegacyLights removed; intensity in candela-ish). PointLight intensity with decay 2 — needs higher values. In r155+, default decay is 2 and intensity units changed; a PointLight with intensity 1 is dim over distance. Set intensity ~40-60, distance 30. Let me use intensity 50, decay 2, distance 28. Flicker between 35 and 65.
Ambient: AmbientLight(0x2a3a55, 0.5)? With physical units ambient intensity works normally. Use dim: 0x223349, 0.35. Hemisphere light: sky 0x1a2740, ground 0x0c120c, 0.4. Both fine.
Directional moon light intensity 0.5? Physical mode: DirectionalLight intensity is in lux-ish but works like before multiplied... In r155+ lights use physical units when renderer.useLegacyLights=false which is now always. DirectionalLight intensity 1 is still reasonable brightness. Use 0.5 with color 0x7f93b8.
Fire glow on ground: an additive circle.
Let me also add subtle blue rim: a large "sky dome"? Background flat color is fine with fog matching.
Camera: fov 55, position (7.5, 4.2, 11), target (0, 1.4, 0). maxPolarAngle: Math.PI/2 - 0.06 (~86°) keeps camera above ground-ish; also minPolarAngle maybe 0.15. minDistance 4, maxDistance 26. enablePan false (keeps it tidy), damping 0.06. Also controls.autoRotate? Slow auto-rotate for demo until user interacts? Nice touch: autoRotate = true, speed 0.4, stop on pointerdown. That makes the first screenshot more alive... screenshot at 3s anyway. Auto-rotate adds life. Enable autoRotate, disable on first interaction ('pointerdown' or 'wheel' or keydown).
Trees casting shadows with point light: point light shadow renders 6 cube faces each frame — with ~34 trees + ground + rocks, geometry counts fine.
Let me count shadows: set castShadow on trees, rocks, logs, ground receiveShadow. Flames/sparks no shadow.
Flame materials MeshBasicMaterial: unaffected by fog? Basic material IS affected by fog by default — fine, flames near camera anyway.
Write the vertex-wobble flame:
```js
function makeFlame(color, radius, height, opacity, speed){
const geo = new THREE.ConeGeometry(radius, height, 6, 4);
geo.translate(0, height/2, 0);
const base = geo.attributes.position.array.slice();
const mat = new THREE.MeshBasicMaterial({color, transparent:true, opacity, blending:THREE.AdditiveBlending, depthWrite:false, side:THREE.DoubleSide});
const mesh = new THREE.Mesh(geo, mat);
mesh.userData = {base, height, radius, speed, phase: Math.random()*Math.PI*2};
flames.push(mesh);
return mesh;
}
```
Update per frame:
```js
for (const f of flames){
const {base, height, phase, speed} = f.userData;
const t = time*speed + phase;
const pos = f.geometry.attributes.position;
for (let i=0;i<pos.count;i++){
const bx = base[i*3], by = base[i*3+1], bz = base[i*3+2];
const k = by/height; // 0 bottom, 1 top
const w = k*k; // more wobble at top
pos.array[i*3] = bx + Math.sin(t + by*3.1 + f.userData.phase)*0.12*w;
pos.array[i*3+2] = bz + Math.cos(t*1.13 + by*2.7)*0.12*w;
pos.array[i*3+1] = by * (1 + 0.08*Math.sin(t*1.7)); // hmm this scales bottom too; bottom is at y=0 so by=0 unaffected... base translated so bottom at 0. Good.
}
pos.needsUpdate = true;
// scale pulse
const s = 1 + 0.12*Math.sin(t*1.9);
f.scale.set(s, 1 + 0.18*Math.sin(t*1.3 + 1), s);
}
```
Note cone apex at top shares vertices per segment? Cone geometry apex has multiple vertices (one per segment) — fine, wobble by y keeps them coherent-ish since same y, but sin(t + by*3.1) with same by gives same offset for same y... but different phase per vertex? All apex vertices same y → same offset, so apex stays a single point? x offsets same value for all apex vertices → they move together, staying coincident. Good.
But wait — base[i*3] for apex vertices differ (different x,z originally at apex they're all 0 actually, apex of cone geometry all vertices at (0, h/2, 0) before translate → coincident). Fine.
Colors of flames stacked: outer big orange (r 0.5, h 1.6, opacity 0.55), mid (0.34, 1.2, 0.7), inner bright yellow (0.2, 0.85, 0.9). Additive blending makes them sum to bright core.
Embers: 5 small tetrahedron/icosa meshes with MeshStandardMaterial emissive 0xff4400, emissiveIntensity flicker 1-2.5, color 0x1a0d06.
Point light position (0, 1.0, 0) inside flames, plus small position jitter (±0.08) for shadow flicker.
Flicker function: intensity = base * (0.82 + 0.18*(Math.sin(t*11)+Math.sin(t*17+1.3)*0.5+Math.sin(t*29+2.1)*0.25)/1.75...) normalize roughly.
Let me also add glow light second point light? One is required; add a very dim fill point light (no shadow) at height 3 to soften? Not necessary.
Ground vertex coloring: base color dark mossy 0x1e2b1a varying with random ±, and near center (d < 2.5) blend to charred dark 0x171310 / dirt 0x2e2418. Compute per-vertex.
Ground displacement:
```js
for each vertex: d = dist from center;
if d>5: y = (Math.random()... ) deterministic noise: use sin(x*0.35)*cos(y*0.3)*0.6 + sin(x*0.13+y*0.21)*1.2, times smoothstep((d-5)/8). Plus tiny random 0.05.
```
Random per-vertex with flat shading gives faceted look — nice low-poly. Use noise + random small.
Grass tufts: 90 small cones (ConeGeometry(0.05, 0.35, 4)) color dark green, random positions d in [2.5, 10], random tilt. Merge? Use InstancedMesh for perf: 100 instances, one material. Set instance matrices once. castShadow false.
Rocks: 12 random + 9 ring rocks. Ring rocks: IcosahedronGeometry(0.22, 0), scaled (1, 0.7, 1) random, positioned at radius 1.5 around fire, random rotation. Random rocks: 10 rocks radius 0.2-0.6 placed d in [2.5, 9].
Also fallen log benches: 2 cylinders (r 0.18, len 2.2, 7 sides) lying at radius ~3, horizontal, plus maybe a stump.
Moon craters? Keep simple: moon with slight vertex color variation? Just plain sphere + halo.
Stars: also a few brighter stars — second Points with size 2. Or vertex colors brightness variation handles it with sizeAttenuation false size 1.5... single size for all. Use two layers: 350 small (size 1.2) and 60 bright (size 2.4). Fine, or custom shader — overkill. Two Points objects.
Twinkle: vary star material opacity subtly? With fog=false, opacity global flicker sin-based subtle: opacity 0.85 + 0.15*sin... applies to all. Fine, subtle.
Performance: fine.
Resize: update camera aspect, renderer size, pixelRatio min(devicePixelRatio, 2).
Hint fade: pointerdown/wheel/keydown → hint.style.opacity = 0; controls.autoRotate = false.
Edge: OrbitControls import from 'three/addons/controls/OrbitControls.js'.
Now the tree placement collision check and also keep trees out of the view path? Not needed.
Also ensure no horizontal scrollbar: body overflow hidden.
Title/HUD: maybe small title top-left "Low-Poly Night Campfire"? A subtle label is nice. Add small header text with letterspacing. Keep minimal.
Let me now write full code carefully.
Tree function:
```js
const trunkMat = new THREE.MeshStandardMaterial({color:0x4a3323, flatShading:true, roughness:1});
const leafMats = [0x1b3a22, 0x21452a, 0x183521, 0x25502e].map(c=>new THREE.MeshStandardMaterial({color:c, flatShading:true, roughness:1}));
function makeTree(){
const g = new THREE.Group();
const s = 0.8 + Math.random()*0.9;
const trunkH = 1.1*s;
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.1*s, 0.16*s, trunkH, 5), trunkMat);
trunk.position.y = trunkH/2;
trunk.castShadow = true;
g.add(trunk);
const mat = leafMats[(Math.random()*leafMats.length)|0];
let y = trunkH*0.8;
let r = (0.85+Math.random()*0.3)*s;
for (let i=0;i<3;i++){
const h = (1.3 - i*0.25)*s;
const cone = new THREE.Mesh(new THREE.ConeGeometry(r, h, 6), mat);
cone.position.y = y + h/2 - 0.05;
cone.rotation.y = Math.random()*Math.PI;
cone.castShadow = true;
g.add(cone);
y += h*0.55;
r *= 0.72;
}
return g;
}
```
Tree tops ~ up to (1.1 + 1.3 + ...) ~ 3.5-4.5 units tall. Good.
Placement:
```js
const treePos = [];
let placed = 0, guard = 0;
while (placed < 36 && guard++ < 800){
const a = Math.random()*Math.PI*2;
const d = 10.5 + Math.random()*22;
const x = Math.cos(a)*d, z = Math.sin(a)*d;
let ok = true;
for (const p of treePos){ const dx=p.x-x, dz=p.z-z; if (dx*dx+dz*dz < 7) {ok=false;break;} } // ~2.6 apart
if (!ok) continue;
treePos.push({x,z});
const t = makeTree();
t.position.set(x, 0, z);
t.rotation.y = Math.random()*Math.PI*2;
scene.add(t);
placed++;
}
```
36 trees, min distance ~2.65. In annulus area π(32.5²-10.5²) ≈ π*950 ≈ 2985, 36 trees with spacing fine.
Also scale trees farther out bigger slightly? Fine as is.
Fog: FogExp2 0x070b12 density 0.03. At 40 units: exp(-(1.2)^2)=0.23 — mostly faded. Trees at 10.5: exp(-(0.315)^2)=0.9 visible. Good.
Moon at distance ~ sqrt(60²+55²+90²) ≈ 120 — with fog disabled on material it stays visible.
Shadow settings: light.shadow.mapSize.set(1024,1024); light.shadow.camera.near=0.3; far=30; bias = -0.005? For point lights bias small negative; also light.shadow.radius doesn't apply to PCFSoft for point? Fine.
renderer.shadowMap.type = THREE.PCFSoftShadowMap.
Sparks update details:
```js
const SPARK_N = 130;
const sPos = new Float32Array(SPARK_N*3);
const sCol = new Float32Array(SPARK_N*3);
const sparks = [];
for i: reset(spark, randomize position initially spread so they appear at start)
reset: x = (rand-0.5)*0.5, y = 0.5+rand*0.8, z=(rand-0.5)*0.5; life = rand (so staggered); maxLife = 1.5+rand*1.5; vx = (rand-.5)*0.35; vy = 1.0+rand*1.4; vz=(rand-.5)*0.35; swirlSeed = rand*10;
update: life -= dt; if life<=0 or y>5.5 → reset with life=maxLife.
x += (vx + Math.sin(time*2.2+seed)*0.35)*dt; y += vy*dt; vy *= slightly? keep.
fade = life/maxLife; color lerp: bright (1,0.75,0.3) → (0.7,0.12,0.02) as fade→0; also overall intensity *= fade.
```
Set colors each frame.
Points material: size 0.09, sizeAttenuation true, vertexColors, blending additive, transparent, depthWrite false, fog: true? sparks near fire fine either way.
Also embers flicker and glow disc pulsation synced with light flicker value.
Let me write the final HTML. Also add a subtle vignette via CSS radial-gradient overlay? A fixed div with pointer-events none, radial-gradient transparent→rgba(0,0,0,0.5). Nice cinematic. Include.
Title overlay: top-left "MIDNIGHT CLEARING" small caps with subtitle "low-poly campfire · three.js". Keep tasteful.
Check first-paint visibility: everything visible immediately; animation running. Good.
Now write it all out.
One more consideration: OrbitControls maxPolarAngle keeps camera above ground — set 1.48 rad (~84.8°) and minDistance 4. Also target y=1.3 fixed; with polar 85° and minDistance 4, camera y = target.y + 4*cos(85°) ≈ 1.3+0.35 = 1.65 > 0 good.
Code:
```js
const clock = new THREE.Clock();
function animate(){
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
time += dt;
...
controls.update();
renderer.render(scene, camera);
}
```
autoRotate: controls.autoRotate = true; autoRotateSpeed = 0.6. Stop on interaction. Also restart never — fine.
Hint: fade after interaction; maybe also auto-fade after 8s? Keep until interaction.
Write final file now. Ensure no console errors: check all variables defined. Careful with geometry attribute copying.
Flame geometry: ConeGeometry(radius, height, 6, 4) → openEnded default false. Vertices count: (4+1) rings * 7? For radial 6, heightSegments 4: (6+1)*(4+1)=35 plus bottom cap 7 (center+6) plus top cap? Cone has one cap (bottom). Total ~42. Fine.
base copy: geo.attributes.position.array.slice() — Float32Array copy, good.
In flame update I referenced f.userData.phase and also included phase in t already; fine, keep single use.
Ground: PlaneGeometry(130,130,46,46) → 47*47=2209 vertices, fine. After rotateX(-PI/2), positions: x = arr[i], z = arr[i+2], y = arr[i+1]. Displace y.
```js
const gGeo = new THREE.PlaneGeometry(130, 130, 46, 46);
gGeo.rotateX(-Math.PI/2);
const gp = gGeo.attributes.position;
const colors = new Float32Array(gp.count*3);
const cGrass = new THREE.Color(0x24371f);
const cGrass2 = new THREE.Color(0x2c3a22);
const cDirt = new THREE.Color(0x3a2c1c);
const cChar = new THREE.Color(0x191410);
for (let i=0;i<gp.count;i++){
const x = gp.getX(i), z = gp.getZ(i);
const d = Math.hypot(x, z);
let y = 0;
if (d > 4.5){
const f = Math.min(1, (d-4.5)/7);
y = (Math.sin(x*0.32)*Math.cos(z*0.28)*0.55 + Math.sin(x*0.11+z*0.17)*1.1 + Math.sin(x*0.07 - z*0.09)*0.8) * f*f + (Math.random()-0.5)*0.12*f;
} else {
y = (Math.random()-0.5)*0.03;
}
gp.setY(i, y);
// color
const n = Math.random();
let c = cGrass.clone().lerp(cGrass2, n);
if (d < 6){ const k = 1 - Math.max(0,(d-2.2))/3.8; c.lerp(cDirt, Math.min(1, k*1.4)); }
if (d < 2.6){ c.lerp(cChar, 0.8); }
colors[i*3]=c.r; colors[i*3+1]=c.g; colors[i*3+2]=c.b;
}
gGeo.setAttribute('color', new THREE.BufferAttribute(colors,3));
gGeo.computeVertexNormals(); // needed for standard material lighting even flat
```
computeVertexNormals with flatShading — flatShading computes per-face in shader? In three.js flatShading uses derivatives in shader, normals attribute ignored? Material flatShading: true uses face normals computed in shader — but geometry still needs normals attribute? With flatShading true, normal attribute is not used (normal_vertex uses... actually in newer three, flat shading is done via computed flat normals in shader when flatShading set; but MeshStandardMaterial may still require normal attribute? Three.js: if flatShading, it defines FLAT_SHADED and computes normals from derivatives — normals attribute not required. But computeVertexNormals anyway harmless.
Material: MeshStandardMaterial({vertexColors:true, flatShading:true, roughness:1, metalness:0}). receiveShadow true.
Grass InstancedMesh:
```js
const gGrass = new THREE.ConeGeometry(0.055, 0.4, 4);
gGrass.translate(0, 0.2, 0);
const grassMat = new THREE.MeshStandardMaterial({color:0x2c4526, flatShading:true, roughness:1});
const grass = new THREE.InstancedMesh(gGrass, grassMat, 140);
const dummy = new THREE.Object3D();
for i: angle, d = 2.8 + Math.random()*8; dummy.position.set(cos*d, 0, sin*d); dummy.rotation.set((Math.random()-.5)*0.5, Math.random()*PI, (Math.random()-.5)*0.5); dummy.scale.setScalar(0.7+Math.random()); updateMatrix; setMatrixAt.
grass.receiveShadow = true? InstancedMesh shadows ok; skip cast.
```
Hmm ground displaced — grass at y=0 might float slightly where ground rose (d>4.5 displacement up to ~2.4 * f²... at d=10, f=1, y up to 2.4!). Oops: displacement amplitude 2.4 units at full — trees at those distances would float/sink! Trees placed on ground must sample ground height. I need a groundHeight(x,z) function used both for displacement and object placement. Let me make displacement purely deterministic (no random component) so I can recompute:
```js
function groundH(x, z){
const d = Math.hypot(x,z);
if (d <= 4.5) return 0;
const f = Math.min(1, (d-4.5)/7);
const w = f*f;
return (Math.sin(x*0.32)*Math.cos(z*0.28)*0.55 + Math.sin(x*0.11+z*0.17)*1.1 + Math.sin(x*0.07 - z*0.09)*0.8) * w;
}
```
Drop the random jitter (or add tiny ±0.03 — ignorable). Then trees/rocks y = groundH(x,z). Since plane vertices lie on grid, groundH matches at vertices exactly; between vertices the mesh linearly interpolates, so objects at arbitrary (x,z) get y=groundH which may differ slightly from interpolated surface — offset objects by -0.05 to sink slightly. Good enough; better: reduce amplitude to ~1.2 total and it's forgiving. Actually amplitude sum max 2.45 — quite hilly, nice. Keep.
Rocks: y = groundH - 0.1*scale to embed. Trees: y = groundH - 0.05. Grass: y = groundH(x,z).
Also camera minDistance 4 maxDistance 26 — camera near ground could clip hills when orbiting at low angle... maxPolarAngle 1.48 rad with target y=1.3, camera could dip below a hill of height 2 at distance 15: camera y = 1.3 + 15*cos(84.7°)=1.3+1.36=2.66 — hills up to 2.45... near ok. Slight risk but acceptable; reduce max amplitude to ~1.8: scale terms: 0.4 + 0.9 + 0.55 → max 1.85. Fine.
Recheck fog vs trees at d up to 33: good.
Moon position (70, 60, -95) — outside fog? Material fog=false. But renderer far plane: camera far set 500. Star radius 240. Set far 600.
Star geometry: hemisphere points:
```js
function makeStars(n, size, brightness){
const pos = new Float32Array(n*3), col = new Float32Array(n*3);
for i:
// random direction with y>0.08
const u = Math.random()*Math.PI*2;
const v = Math.acos(Math.random()*(1-0.1)+0.1)??
```
Simpler:
```js
let x,y,z,l;
do { x=Math.random()*2-1; y=Math.random(); z=Math.random()*2-1; l=Math.hypot(x,y,z);} while (l>1 || l<0.2 || y/l < 0.12);
normalize, scale 240.
```
Colors: white with slight tint: r=1, g=0.95..1, b=0.85..1, multiplied brightness random 0.5–1. Material: PointsMaterial({size, sizeAttenuation:false, vertexColors:true, transparent:true, opacity, fog:false, depthWrite:false}).
sizeAttenuation false with size in pixels — size 1.6 and 3 for bright layer. With pixelRatio 2 the size is in... drawing buffer pixels? size is in pixels of canvas — at DPR2 looks smaller relative. Use size 2 and 3.5.
Twinkle: bright layer material opacity = 0.7+0.3*sin(t*1.7 + ...) — global, subtle. ok.
Moon halo: sphere radius 11, MeshBasicMaterial({color:0x9fb4d8, transparent:true, opacity:0.12, blending:AdditiveBlending, fog:false, depthWrite:false}) — wait, halo sphere bigger than moon, moon inside — additive halo in front of moon adds to it, fine. Render order issues: set halo renderOrder... additive depthWrite false draws over moon brightening — acceptable glow effect.
Directional moonlight from moon position: dir light position (70,60,-95), target origin — set light.position and default target(0,0,0). Intensity 0.5? Physical mode: directional intensity ~0.4 gives dim blue fill. Let me use 0.45, color 0x8fa5cc.
Also a faint cool AmbientLight 0x1c2a40 intensity 0.6? Hmm in r170 physical lighting, AmbientLight intensity works as multiplier — 0.5 is fine dim. Let me tune: ambient 0x30425e × 0.4? Might look too bright/flat. Use 0.35. Hemisphere adds nice gradient: HemisphereLight(0x22334d, 0x0e1410, 0.5). Skip ambient then, or both small. I'll use hemisphere 0.55 only + moon directional 0.4 + fire point 50.
Actually, three r170: lights physical units — PointLight intensity is in candela when decay=2... intensity 50 at distance 5 gives irradiance 50/25=2 — bright. At distance 10: 0.5. Good. Flicker base 55.
Emissive embers: MeshStandardMaterial emissiveIntensity ~2.
Tone mapping ACES exposure 1.15.
Glow disc: CircleGeometry(1.8) rotated flat at y=0.02, MeshBasicMaterial({color:0xff6a1f, transparent, opacity 0.35, blending additive, depthWrite false}). Pulse opacity with flicker. Might create z-fighting with ground at y≈0 near center (ground flat y≈0 there). Put at y=0.04, polygonOffset not needed. Ground near center is flat (d<4.5 → y=0 ± tiny random 0 removed). I removed random near center — set y=0 exactly. Then disc at 0.04 fine.
Now the light shadow bias: point light shadows on ground from logs/rocks — bias -0.002, or normalBias 0.02. Use shadow.bias = -0.008? Point light shadow acne on flat ground: normalBias works better: shadow.normalBias = 0.05? For point lights normalBias supported. Set shadow.bias = -0.005, normalBias = 0.02.
Let me write out the entire file now.
Also: trees far (33) with fog 0.03: exp(-(0.99)²)=0.37 — partially faded, depth feel. Good. Fog color matches bg 0x070b12 — but with stars, fog color should match horizon sky. It's fine.
Check: "no horizontal scrollbar" — body overflow hidden.
Write the code.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Midnight Clearing — Low-Poly Campfire</title>
<style>
html, body { margin:0; height:100%; overflow:hidden; background:#070b12; }
canvas { display:block; }
#ui { position:fixed; inset:0; pointer-events:none; font-family:system-ui, -apple-system, sans-serif; }
#title { position:absolute; top:22px; left:26px; color:#e8dcc3; opacity:.9; }
#title h1 { margin:0; font-size:15px; font-weight:600; letter-spacing:.34em; text-transform:uppercase; text-shadow:0 2px 10px rgba(0,0,0,.9); }
#title p { margin:4px 0 0; font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:#8a94a8; text-shadow:0 1px 6px rgba(0,0,0,.9); }
#hint { position:absolute; bottom:20px; left:50%; transform:translateX(-50%); font-size:11px; letter-spacing:.24em; text-transform:uppercase; color:#c9b891; opacity:.8; text-shadow:0 1px 8px rgba(0,0,0,.95); transition:opacity 1.2s ease; white-space:nowrap; }
#vignette { position:absolute; inset:0; background:radial-gradient(ellipse at 50% 42%, transparent 52%, rgba(2,4,8,.55) 100%); }
.faded { opacity:0 !important; }
</style>
<script type="importmap">
{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"}}
</script>
</head>
<body>
<div id="ui">
<div id="title"><h1>Midnight Clearing</h1><p>low-poly campfire · three.js</p></div>
<div id="vignette"></div>
<div id="hint">drag to orbit · scroll to zoom</div>
</div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
...
</script>
</body>
</html>
```
Now full JS.
```js
// ---------- renderer / scene / camera ----------
const renderer = new THREE.WebGLRenderer({ antialias:true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x070b12);
scene.fog = new THREE.FogExp2(0x070b12, 0.03);
const camera = new THREE.PerspectiveCamera(55, innerWidth/innerHeight, 0.1, 600);
camera.position.set(8.5, 4.6, 11.5);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1.3, 0);
controls.enableDamping = true;
controls.dampingFactor = 0.06;
controls.minDistance = 4;
controls.maxDistance = 26;
controls.maxPolarAngle = Math.PI/2 - 0.07;
controls.minPolarAngle = 0.15;
controls.enablePan = false;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.5;
controls.update();
// ---------- ground ----------
function groundH(x, z){...}
```
Then rest. Also spark seed usage.
One thing: `innerWidth` bare — fine in browser, but use window.innerWidth for clarity.
Rocks: ring stones and scatter rocks share material `rockMat` gray 0x6a6d70 flatShading. Actually at night with only firelight, distant rocks dark — fine. Vary colors slightly per rock: material clone with color offset; ok, few rocks.
Fallen logs benches: two logs at radius ~3.2, lying horizontally, oriented tangentially. Cylinder(0.18, 0.2, 2.4, 7), rotate z = PI/2 to lie along X, then rotate y random angle. y = groundH + 0.16.
Also a couple of chopped-wood pieces near fire: small cylinders stacked? Add 3 small log segments stacked sideways near one bench — cute detail. Keep code short: a small pile at (2.6, ..., 1.8): 3 horizontal short cylinders (r0.14,len0.5) stacked pyramid.
Total code getting long but fine.
Now let me also double check ConeGeometry translate for flames and embers positioning.
Embers: 6 small Icosahedron(0.09, 0) meshes at random positions within radius 0.55, y 0.08-0.2, emissive material (shared but flicker per-mesh needs separate material or emissiveIntensity per material). Share one material and flicker globally — simpler and looks fine.
Point light y ~1.0.
Flame cones: heights 1.7 (outer, radius 0.55), 1.25 mid 0.36, 0.9 inner 0.2. Position group at (0, 0.1, 0).
Also fire smoke? Not required. Skip.
Sparks seed: store per-spark object.
```js
const SPARKS = 140;
const sGeo = new THREE.BufferGeometry();
const sPos = new Float32Array(SPARKS*3);
const sCol = new Float32Array(SPARKS*3);
const sData = [];
for (let i=0;i<SPARKS;i++){
sData.push({}); resetSpark(i, true);
}
function resetSpark(i, init){
const d = sData[i];
d.maxLife = 1.4 + Math.random()*1.6;
d.life = init ? Math.random()*d.maxLife : d.maxLife;
d.x = (Math.random()-0.5)*0.5;
d.y = 0.5 + Math.random()*0.7;
d.z = (Math.random()-0.5)*0.5;
d.vx = (Math.random()-0.5)*0.4;
d.vy = 0.9 + Math.random()*1.5;
d.vz = (Math.random()-0.5)*0.4;
d.seed = Math.random()*100;
}
sGeo.setAttribute('position', new THREE.BufferAttribute(sPos,3));
sGeo.setAttribute('color', new THREE.BufferAttribute(sCol,3));
const sparkMat = new THREE.PointsMaterial({ size:0.09, sizeAttenuation:true, vertexColors:true, transparent:true, opacity:0.95, blending:THREE.AdditiveBlending, depthWrite:false });
const sparkPoints = new THREE.Points(sGeo, sparkMat);
sparkPoints.frustumCulled = false;
scene.add(sparkPoints);
```
Update:
```js
const cA = {r:1.0,g:0.72,b:0.28}, cB = {r:0.55,g:0.08,b:0.01};
for i:
d.life -= dt;
if (d.life <= 0 || d.y > 5.5) resetSpark(i, false);
d.x += (d.vx + Math.sin(time*2.3 + d.seed)*0.4)*dt;
d.z += (d.vz + Math.cos(time*2.0 + d.seed)*0.4)*dt;
d.y += d.vy*dt;
const f = Math.max(0, d.life/d.maxLife);
sPos[...]=d.x...;
sCol: mix cB->cA by f, times (0.3+0.7*f)? color = lerp(cB, cA, f) gives dim red as dies. Also multiply by f for fade:
sCol = lerp * (0.25+0.75*f)
```
Fine.
Interaction handler:
```js
function onInteract(){
controls.autoRotate = false;
document.getElementById('hint').classList.add('faded');
window.removeEventListener('pointerdown', onInteract);
window.removeEventListener('wheel', onInteract);
window.removeEventListener('keydown', onInteract);
}
addEventListener('pointerdown', onInteract); etc.
```
Flicker:
```js
function flick(t){
return 0.72 + 0.28*(0.5 + 0.5*Math.sin(t*11.3)) * ...
```
Better: value = 0.75 + 0.25*(Math.sin(t*11.0)*0.45 + Math.sin(t*17.7+1.7)*0.3 + Math.sin(t*29.3+4.2)*0.25); range roughly 0.75±0.25 → 0.5–1.0. Good.
fireLight.intensity = 55 * flick; fireLight.position.set((Math.random()-0.5)*0.12, 1.0+ (Math.random()-0.5)*0.1, ...) — random jitter each frame may be too jittery for shadows; use smooth sin-based: x = 0.07*sin(t*13.1), y = 1.0 + 0.06*sin(t*9.7), z = 0.07*cos(t*11.3).
glowDisc.material.opacity = 0.28 + 0.18*flickVal.
emberMat.emissiveIntensity = 1.4 + 1.4*flickVal.
Stars twinkle: brightStarMat.opacity = 0.55 + 0.35*(0.5+0.5*Math.sin(time*1.3)); also small layer constant.
Also maybe animate moon slowly? No.
Resize:
```js
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth/window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
```
Wood pile & stumps: add stump: cylinder(0.28, 0.34, 0.5, 7) at some position with lighter top? fine.
Let me make sure trees count ≥ 30: aim 38.
Also "logs around a campfire" — teepee logs count as logs around fire, plus fallen logs. Good.
Now assemble everything into final answer. Watch out: PlaneGeometry vertex colors need geometry.setAttribute. computeVertexNormals after displacement.
One more check on flame update loop with base positions: base is the array copy AFTER translate (bottom at y=0, top y=height). k = by/height in [0,1]. wobble amplitude 0.13*w. y-scale pulse: by * (1+0.07*sin) modifies all y — bottom 0 stays 0. Good.
Scale via f.scale: combined with vertex wobble fine.
Also set flame render order? Additive with depthWrite false: render after ground — three sorts transparent by distance, fine.
Rotation of flames: rotate each slowly rotation.y += dt*speed*0.5 for extra motion. With 6-segment cone visible rotation. OK.
Ember glow also: give logs near fire slight emissive? The teepee log material shared with others; make inner logs a separate slightly charred material color 0x3a2a1c with emissive 0x882205, emissiveIntensity 0.25 — glowing-hot logs. Nice.
Final check on console errors: 'three/addons/controls/OrbitControls.js' path valid. importmap correct. No typos in variable names — I'll be careful.
Write it.