MiniMax M3.1 Flash
by MiniMaxReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 1 min 46 s
- Cost
- $0.014
- Output tokens
- 23,076
- Spent thinking
- 11,798 tokens
- Speed
- 218.6 tok/s
- Console errors
- 0
- First token after
- 404 ms
- First visible text after
- 1 min 3 s
- Prompt tokens
- 734
- Code size
- 31.9 KB
- Finish reason
- stop
- Served by
- Nebius
- Model id
minimax/minimax-m3.1-flash- Reasoning sent
model default- Max output
- 524,288 tokens
- Generated
- Nov 13, 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 · 902 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>Nightfall — Campfire Clearing</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background: #05070d; }
body {
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
#app { position: fixed; inset: 0; }
#app canvas { display: block; width: 100%; height: 100%; touch-action: none; }
/* cinematic vignette */
#vignette {
position: fixed; inset: 0; pointer-events: none; z-index: 5;
background:
radial-gradient(120% 90% at 50% 45%, rgba(0,0,0,0) 45%, rgba(0,0,0,0.55) 100%),
radial-gradient(60% 50% at 50% 55%, rgba(255,140,50,0.055) 0%, rgba(0,0,0,0) 70%);
mix-blend-mode: normal;
}
#hud { position: fixed; inset: 0; pointer-events: none; z-index: 10; color: #cfd8e6; }
.title {
position: absolute; top: clamp(14px, 3vh, 34px); left: clamp(14px, 3vw, 38px);
text-shadow: 0 2px 18px rgba(0,0,0,0.85);
}
.eyebrow {
font-size: 10px; letter-spacing: .42em; text-transform: uppercase;
color: #f0a24a; opacity: .95; margin-bottom: 8px; font-weight: 600;
}
h1 {
margin: 0; font-size: clamp(20px, 3.4vw, 34px); font-weight: 300; letter-spacing: .06em;
color: #eef3fb;
}
.sub { margin-top: 7px; font-size: 11.5px; letter-spacing: .13em; color: #8fa0b8; text-transform: uppercase; }
#hint {
position: absolute; left: 50%; bottom: clamp(14px, 3vh, 28px); transform: translateX(-50%);
font-size: 11px; letter-spacing: .18em; text-transform: uppercase; color: #9fb0c6;
background: rgba(8,12,20,.45); border: 1px solid rgba(160,190,230,.13);
padding: 8px 16px; border-radius: 999px; backdrop-filter: blur(6px);
transition: opacity 1.1s ease; white-space: nowrap;
}
#hint b { color: #f4a653; font-weight: 600; }
#hint.gone { opacity: 0; }
#loader {
position: fixed; inset: 0; z-index: 40; display: grid; place-items: center;
background: #05070d; color: #f0a24a; font-size: 11px; letter-spacing: .35em; text-transform: uppercase;
transition: opacity .8s ease;
}
#loader.hide { opacity: 0; pointer-events: none; }
@media (max-width: 560px) {
.sub { display: none; }
#hint { font-size: 9.5px; letter-spacing: .12em; }
}
</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="app"></div>
<div id="vignette"></div>
<div id="hud">
<div class="title">
<div class="eyebrow">Nightfall</div>
<h1>Campfire Clearing</h1>
<div class="sub">Low‑poly forest · procedural scene</div>
</div>
<div id="hint"><b>Drag</b> to orbit · <b>Scroll</b> to zoom · <b>Right‑drag</b> to pan</div>
</div>
<div id="loader">Kindling the fire…</div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
/* ------------------------------------------------------------------ *
* Renderer / scene basics
* ------------------------------------------------------------------ */
const app = document.getElementById('app');
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x070b14);
scene.fog = new THREE.Fog(0x0a1220, 17, 96);
const camera = new THREE.PerspectiveCamera(52, window.innerWidth / window.innerHeight, 0.1, 700);
camera.position.set(9.2, 4.6, 10.4);
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.75));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.18;
app.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1.35, 0);
controls.enableDamping = true;
controls.dampingFactor = 0.055;
controls.rotateSpeed = 0.62;
controls.zoomSpeed = 0.85;
controls.panSpeed = 0.5;
controls.minDistance = 4.2;
controls.maxDistance = 64;
controls.minPolarAngle = 0.12;
controls.maxPolarAngle = Math.PI * 0.495; // camera stays above the ground plane
controls.autoRotate = true;
controls.autoRotateSpeed = 0.32;
controls.addEventListener('start', () => {
controls.autoRotate = false;
document.getElementById('hint').classList.add('gone');
});
setTimeout(() => document.getElementById('hint').classList.add('gone'), 12000);
/* ------------------------------------------------------------------ *
* Small helpers
* ------------------------------------------------------------------ */
const rnd = (a, b) => a + Math.random() * (b - a);
const pick = (arr) => arr[(Math.random() * arr.length) | 0];
/** continuous pseudo-noise terrain, flattened in the middle (the clearing) */
function rawHeight(x, z) {
return Math.sin(x * 0.085) * Math.cos(z * 0.075) * 1.75
+ Math.sin(x * 0.23 + 1.3) * 0.48
+ Math.cos(z * 0.19 - 0.4) * 0.44
+ Math.sin((x + z) * 0.047) * 1.05;
}
function groundY(x, z) {
const d = Math.hypot(x, z);
const f = THREE.MathUtils.smoothstep(d, 7.5, 27); // flat clearing, rolling forest beyond
return rawHeight(x, z) * f;
}
/* canvas texture helpers (no external assets) */
function radialTexture(stops, size = 128) {
const c = document.createElement('canvas');
c.width = c.height = size;
const ctx = c.getContext('2d');
const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
stops.forEach(([p, col]) => g.addColorStop(p, col));
ctx.fillStyle = g;
ctx.fillRect(0, 0, size, size);
const t = new THREE.CanvasTexture(c);
t.colorSpace = THREE.SRGBColorSpace;
return t;
}
const glowTex = radialTexture([
[0.0, 'rgba(255,255,255,1)'],
[0.25, 'rgba(255,236,190,0.75)'],
[0.55, 'rgba(255,150,60,0.22)'],
[1.0, 'rgba(255,120,40,0)'],
]);
const haloTex = radialTexture([
[0.0, 'rgba(200,220,255,0.85)'],
[0.35, 'rgba(160,190,255,0.22)'],
[1.0, 'rgba(120,160,255,0)'],
]);
const smokeTex = radialTexture([
[0.0, 'rgba(150,150,160,0.55)'],
[0.5, 'rgba(110,110,125,0.22)'],
[1.0, 'rgba(90,90,105,0)'],
]);
/* ------------------------------------------------------------------ *
* Sky dome + stars + moon
* ------------------------------------------------------------------ */
const skyMat = new THREE.ShaderMaterial({
side: THREE.BackSide,
depthWrite: false,
fog: false,
vertexShader: /* glsl */`
varying vec3 vPos;
void main(){
vPos = position;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: /* glsl */`
varying vec3 vPos;
void main(){
float h = normalize(vPos).y;
vec3 horizon = vec3(0.052, 0.075, 0.115);
vec3 mid = vec3(0.021, 0.032, 0.072);
vec3 top = vec3(0.006, 0.010, 0.028);
vec3 c = mix(horizon, mid, smoothstep(-0.08, 0.34, h));
c = mix(c, top, smoothstep(0.22, 0.92, h));
// faint warm lift towards the fire side (bottom of the dome)
c += vec3(0.030, 0.014, 0.003) * smoothstep(0.05, -0.35, h) * 0.6;
gl_FragColor = vec4(c, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`
});
const sky = new THREE.Mesh(new THREE.SphereGeometry(300, 32, 20), skyMat);
sky.renderOrder = -10;
scene.add(sky);
/* stars */
{
const COUNT = 1500;
const pos = new Float32Array(COUNT * 3);
const col = new Float32Array(COUNT * 3);
const c = new THREE.Color();
for (let i = 0; i < COUNT; i++) {
const u = Math.random() * 2 - 1;
const th = Math.random() * Math.PI * 2;
const s = Math.sqrt(1 - u * u);
const r = 250 + Math.random() * 20;
pos[i * 3 + 0] = Math.cos(th) * s * r;
pos[i * 3 + 1] = Math.abs(u) * r * 0.98 + 4;
pos[i * 3 + 2] = Math.sin(th) * s * r;
const warm = Math.random();
c.setRGB(0.72 + 0.28 * warm, 0.78 + 0.2 * (1 - warm), 0.95 + 0.05 * warm);
const b = 0.35 + Math.pow(Math.random(), 2.2) * 0.9;
col[i * 3 + 0] = c.r * b; col[i * 3 + 1] = c.g * b; col[i * 3 + 2] = c.b * b;
}
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: 1.9, sizeAttenuation: false, vertexColors: true,
transparent: true, opacity: 0.95, depthWrite: false, fog: false
});
const stars = new THREE.Points(g, m);
stars.renderOrder = -9;
scene.add(stars);
scene.userData.stars = stars;
}
/* moon */
const moonDir = new THREE.Vector3(-0.56, 0.47, -0.68).normalize();
{
const size = 256;
const c = document.createElement('canvas');
c.width = c.height = size;
const ctx = c.getContext('2d');
ctx.fillStyle = '#e9e6d8';
ctx.fillRect(0, 0, size, size);
for (let i = 0; i < 26; i++) {
const x = Math.random() * size, y = Math.random() * size, r = rnd(4, 22);
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, 'rgba(170,168,155,0.55)');
g.addColorStop(1, 'rgba(200,198,185,0)');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
}
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
const moon = new THREE.Mesh(
new THREE.SphereGeometry(7.5, 24, 16),
new THREE.MeshBasicMaterial({ map: tex, fog: false })
);
moon.position.copy(moonDir).multiplyScalar(235);
scene.add(moon);
const halo = new THREE.Sprite(new THREE.SpriteMaterial({
map: haloTex, color: 0xbdd2ff, transparent: true, opacity: 0.75,
blending: THREE.AdditiveBlending, depthWrite: false, fog: false
}));
halo.position.copy(moon.position);
halo.scale.setScalar(62);
scene.add(halo);
}
/* ------------------------------------------------------------------ *
* Lighting
* ------------------------------------------------------------------ */
scene.add(new THREE.HemisphereLight(0x3a5177, 0x0b1009, 0.55));
scene.add(new THREE.AmbientLight(0x121c2b, 0.55));
const moonLight = new THREE.DirectionalLight(0x9fb8ff, 0.85);
moonLight.position.copy(moonDir).multiplyScalar(90);
moonLight.castShadow = true;
moonLight.shadow.mapSize.set(2048, 2048);
moonLight.shadow.camera.near = 1;
moonLight.shadow.camera.far = 220;
moonLight.shadow.camera.left = -46;
moonLight.shadow.camera.right = 46;
moonLight.shadow.camera.top = 46;
moonLight.shadow.camera.bottom = -46;
moonLight.shadow.bias = -0.0007;
moonLight.shadow.normalBias = 0.045;
scene.add(moonLight);
scene.add(moonLight.target);
/* the fire light */
const fireLight = new THREE.PointLight(0xff8c33, 46, 44, 2);
fireLight.position.set(0, 1.05, 0);
fireLight.castShadow = true;
fireLight.shadow.mapSize.set(1024, 1024);
fireLight.shadow.camera.near = 0.25;
fireLight.shadow.camera.far = 27;
fireLight.shadow.bias = -0.006;
fireLight.shadow.normalBias = 0.03;
scene.add(fireLight);
/* small warm bounce light near the ground */
const bounce = new THREE.PointLight(0xff5a12, 6, 12, 2);
bounce.position.set(0, 0.18, 0);
scene.add(bounce);
/* ------------------------------------------------------------------ *
* Ground
* ------------------------------------------------------------------ */
{
const SEG = 96, SIZE = 340;
const geo = new THREE.PlaneGeometry(SIZE, SIZE, SEG, SEG);
geo.rotateX(-Math.PI / 2);
const p = geo.attributes.position;
const colors = new Float32Array(p.count * 3);
const cA = new THREE.Color(0x1a2a1c);
const cB = new THREE.Color(0x24361f);
const cD = new THREE.Color(0x2b2a20);
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, groundY(x, z));
const n = (Math.sin(x * 0.37) * Math.cos(z * 0.31) + Math.sin(x * 0.11 + z * 0.17) * 1.4) * 0.5 + 0.5;
const d = Math.hypot(x, z);
c.copy(cA).lerp(cB, n);
c.lerp(cD, THREE.MathUtils.smoothstep(d, 4, 11) * 0.35);
c.multiplyScalar(0.75 + n * 0.4);
colors[i * 3] = c.r; colors[i * 3 + 1] = c.g; colors[i * 3 + 2] = c.b;
}
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geo.computeVertexNormals();
const mat = new THREE.MeshStandardMaterial({
vertexColors: true, roughness: 1, metalness: 0, flatShading: true
});
const ground = new THREE.Mesh(geo, mat);
ground.receiveShadow = true;
scene.add(ground);
}
/* trampled dirt around the fire pit */
{
const dirt = new THREE.Mesh(
new THREE.CircleGeometry(3.4, 16),
new THREE.MeshStandardMaterial({ color: 0x2e2419, roughness: 1, flatShading: true })
);
dirt.rotation.x = -Math.PI / 2;
dirt.position.y = 0.015;
dirt.receiveShadow = true;
scene.add(dirt);
}
/* warm light pool decal on the ground */
{
const pool = new THREE.Mesh(
new THREE.CircleGeometry(7.2, 28),
new THREE.MeshBasicMaterial({
map: glowTex, color: 0xff9a45, transparent: true, opacity: 0.42,
blending: THREE.AdditiveBlending, depthWrite: false, fog: true
})
);
pool.rotation.x = -Math.PI / 2;
pool.position.y = 0.03;
scene.add(pool);
}
/* ------------------------------------------------------------------ *
* Rocks
* ------------------------------------------------------------------ */
const rockMat = new THREE.MeshStandardMaterial({ color: 0x3b4046, roughness: 0.95, flatShading: true });
const rockMat2 = new THREE.MeshStandardMaterial({ color: 0x4a4b46, roughness: 0.95, flatShading: true });
function makeRockGeo() {
const g = new THREE.IcosahedronGeometry(1, 0);
const p = g.attributes.position;
for (let i = 0; i < p.count; i++) {
const x = p.getX(i), y = p.getY(i), z = p.getZ(i);
// continuous function => duplicated vertices stay welded (no cracks)
const s = 0.78 + 0.30 * Math.sin(x * 6.1 + y * 3.7 + z * 5.3) + 0.14 * Math.sin(z * 9.7 - x * 4.1);
p.setXYZ(i, x * s, y * s * 0.78, z * s);
}
g.computeVertexNormals();
return g;
}
const rockGeo = makeRockGeo();
function addRock(x, z, s, mat = rockMat, sink = 0.35) {
const m = new THREE.Mesh(rockGeo, mat);
m.position.set(x, groundY(x, z) - s * sink, z);
m.rotation.set(rnd(0, 6.28), rnd(0, 6.28), rnd(0, 6.28));
m.scale.set(s * rnd(0.85, 1.25), s * rnd(0.7, 1.05), s * rnd(0.85, 1.25));
m.castShadow = true;
m.receiveShadow = true;
scene.add(m);
return m;
}
/* scattered boulders */
for (let i = 0; i < 22; i++) {
const a = rnd(0, Math.PI * 2);
const r = rnd(4.5, 30);
addRock(Math.cos(a) * r, Math.sin(a) * r, rnd(0.3, 1.15), Math.random() < 0.4 ? rockMat2 : rockMat);
}
/* fire-ring stones */
for (let i = 0; i < 10; i++) {
const a = (i / 10) * Math.PI * 2 + rnd(-0.16, 0.16);
const r = 1.42 + rnd(-0.1, 0.12);
addRock(Math.cos(a) * r, Math.sin(a) * r, rnd(0.24, 0.36), rockMat2, 0.55);
}
/* ------------------------------------------------------------------ *
* Logs
* ------------------------------------------------------------------ */
const barkMat = new THREE.MeshStandardMaterial({ color: 0x36251a, roughness: 1, flatShading: true });
const barkMat2 = new THREE.MeshStandardMaterial({ color: 0x2a1d14, roughness: 1, flatShading: true });
const cutMat = new THREE.MeshStandardMaterial({ color: 0x6a5233, roughness: 1, flatShading: true });
function addLog(from, to, radius, mat = barkMat) {
const dir = new THREE.Vector3().subVectors(to, from);
const len = dir.length();
const geo = new THREE.CylinderGeometry(radius * 0.9, radius, len, 7, 1);
const m = new THREE.Mesh(geo, mat);
m.position.copy(from).add(to).multiplyScalar(0.5);
m.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.clone().normalize());
m.castShadow = true;
m.receiveShadow = true;
scene.add(m);
// cut ends
[from, to].forEach((p, i) => {
const cap = new THREE.Mesh(new THREE.CircleGeometry(radius * 0.92, 7), cutMat);
cap.position.copy(p);
cap.quaternion.copy(m.quaternion);
if (i === 1) cap.rotateY(Math.PI);
cap.rotateX(Math.PI / 2);
cap.quaternion.copy(m.quaternion).multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), Math.PI / 2));
scene.add(cap);
});
return m;
}
/* logs lying around the pit */
for (let i = 0; i < 4; i++) {
const a = (i / 4) * Math.PI * 2 + 0.5;
const r = 2.15;
const cx = Math.cos(a) * r, cz = Math.sin(a) * r;
const dir = a + Math.PI / 2 + rnd(-0.3, 0.3);
addLog(
new THREE.Vector3(cx - Math.cos(dir) * 1.15, 0.2, cz - Math.sin(dir) * 1.15),
new THREE.Vector3(cx + Math.cos(dir) * 1.15, 0.2, cz + Math.sin(dir) * 1.15),
rnd(0.16, 0.23), Math.random() < 0.5 ? barkMat : barkMat2
);
}
/* teepee of logs over the flame */
for (let i = 0; i < 3; i++) {
const a = (i / 3) * Math.PI * 2 + 0.3;
const bx = Math.cos(a) * 1.05, bz = Math.sin(a) * 1.05;
addLog(
new THREE.Vector3(bx, 0.14, bz),
new THREE.Vector3(bx * 0.16, 1.62, bz * 0.16),
0.14, barkMat2
);
}
/* a couple of logs out in the clearing */
for (let i = 0; i < 5; i++) {
const a = rnd(0, Math.PI * 2), r = rnd(6, 17);
const x = Math.cos(a) * r, z = Math.sin(a) * r;
const dir = rnd(0, Math.PI * 2);
addLog(
new THREE.Vector3(x - Math.cos(dir) * 1.4, groundY(x, z) + 0.22, z - Math.sin(dir) * 1.4),
new THREE.Vector3(x + Math.cos(dir) * 1.4, groundY(x, z) + 0.22, z + Math.sin(dir) * 1.4),
rnd(0.2, 0.32), Math.random() < 0.5 ? barkMat : barkMat2
);
}
/* ------------------------------------------------------------------ *
* Trees (>= 30, random placement, never on the fire)
* ------------------------------------------------------------------ */
const trunkGeo = new THREE.CylinderGeometry(0.3, 0.5, 2.2, 6, 1);
trunkGeo.translate(0, 1.1, 0);
const coneGeo = new THREE.ConeGeometry(1, 1, 7, 1);
const trunkMat = new THREE.MeshStandardMaterial({ color: 0x2a1e15, roughness: 1, flatShading: true });
const leafMats = [0x16301d, 0x1b3a23, 0x11241a, 0x22402a, 0x183523].map(c =>
new THREE.MeshStandardMaterial({ color: c, roughness: 1, flatShading: true }));
function makeTree(seed) {
const g = new THREE.Group();
const trunk = new THREE.Mesh(trunkGeo, trunkMat);
g.add(trunk);
const mat = leafMats[seed % leafMats.length];
const layers = [[1.62, 2.35, 2.5], [1.18, 1.95, 3.4], [0.72, 1.55, 4.2]];
for (const [r, h, y] of layers) {
const m = new THREE.Mesh(coneGeo, mat);
m.scale.set(r, h, r);
m.position.y = y;
g.add(m);
}
return g;
}
function makeDeadTree() {
const g = new THREE.Group();
const t = new THREE.Mesh(trunkGeo, trunkMat);
t.scale.set(0.7, 1.5, 0.7);
g.add(t);
for (let i = 0; i < 3; i++) {
const b = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.11, 1.7, 5), trunkMat);
b.position.set(rnd(-0.4, 0.4), 1.7 + i * 0.55, rnd(-0.4, 0.4));
b.rotation.set(rnd(-1.1, 1.1), rnd(0, 6.28), rnd(-1.1, 1.1));
g.add(b);
}
return g;
}
const TREE_COUNT = 54;
let placed = 0, attempts = 0;
while (placed < TREE_COUNT && attempts < 900) {
attempts++;
const a = rnd(0, Math.PI * 2);
const r = 12.5 + Math.pow(Math.random(), 0.62) * 46;
const x = Math.cos(a) * r, z = Math.sin(a) * r;
const dead = Math.random() < 0.14;
const tree = dead ? makeDeadTree() : makeTree(placed);
const s = rnd(0.75, 1.75);
tree.scale.setScalar(s);
tree.position.set(x, groundY(x, z) - 0.1, z);
tree.rotation.y = rnd(0, Math.PI * 2);
tree.traverse(o => { if (o.isMesh) { o.castShadow = r < 30; o.receiveShadow = true; } });
scene.add(tree);
placed++;
}
/* distant silhouette treeline */
{
const mat = new THREE.MeshBasicMaterial({ color: 0x0a1019, fog: true });
const g = new THREE.Group();
for (let i = 0; i < 46; i++) {
const a = (i / 46) * Math.PI * 2 + rnd(-0.05, 0.05);
const r = rnd(74, 104);
const h = rnd(9, 17);
const m = new THREE.Mesh(new THREE.ConeGeometry(rnd(2.2, 4.0), h, 5), mat);
m.position.set(Math.cos(a) * r, h / 2 - 1.5, Math.sin(a) * r);
m.rotation.y = rnd(0, 6.28);
g.add(m);
}
scene.add(g);
}
/* bushes */
{
const bushGeo = new THREE.IcosahedronGeometry(1, 0);
for (let i = 0; i < 26; i++) {
const a = rnd(0, Math.PI * 2), r = rnd(5, 22);
const x = Math.cos(a) * r, z = Math.sin(a) * r;
const m = new THREE.Mesh(bushGeo, leafMats[(Math.random() * leafMats.length) | 0]);
const s = rnd(0.35, 0.85);
m.scale.set(s, s * 0.7, s);
m.position.set(x, groundY(x, z) + s * 0.35, z);
m.rotation.set(rnd(0, 6.28), rnd(0, 6.28), rnd(0, 6.28));
m.castShadow = true;
scene.add(m);
}
}
/* grass tufts */
{
const verts = [];
for (let i = 0; i < 520; i++) {
const a = rnd(0, Math.PI * 2);
const r = 3.3 + Math.pow(Math.random(), 0.7) * 15;
const x = Math.cos(a) * r, z = Math.sin(a) * r;
const y = groundY(x, z);
const h = rnd(0.22, 0.55), w = rnd(0.05, 0.09);
const rot = rnd(0, Math.PI);
const dx = Math.cos(rot) * w, dz = Math.sin(rot) * w;
const lx = Math.cos(rot + 1.2) * h * 0.35, lz = Math.sin(rot + 1.2) * h * 0.35;
verts.push(x - dx, y, z - dz, x + dx, y, z + dz, x + lx, y + h, z + lz);
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
g.computeVertexNormals();
const m = new THREE.Mesh(g, new THREE.MeshStandardMaterial({
color: 0x2b4326, roughness: 1, side: THREE.DoubleSide, flatShading: true
}));
m.receiveShadow = true;
scene.add(m);
}
/* ------------------------------------------------------------------ *
* Campfire: embers, flames, glow
* ------------------------------------------------------------------ */
const emberMat = new THREE.MeshStandardMaterial({
color: 0x1d0c05, emissive: 0xff5212, emissiveIntensity: 2.4, roughness: 1, flatShading: true
});
{
const eg = new THREE.IcosahedronGeometry(1, 0);
for (let i = 0; i < 22; i++) {
const a = rnd(0, Math.PI * 2), r = rnd(0, 0.95);
const m = new THREE.Mesh(eg, emberMat);
const s = rnd(0.07, 0.18);
m.scale.set(s, s * 0.7, s);
m.position.set(Math.cos(a) * r, rnd(0.02, 0.1), Math.sin(a) * r);
m.rotation.set(rnd(0, 6.28), rnd(0, 6.28), rnd(0, 6.28));
scene.add(m);
}
}
/* charred log in the fire */
addLog(new THREE.Vector3(-0.55, 0.16, -0.3), new THREE.Vector3(0.6, 0.2, 0.35), 0.16, new THREE.MeshStandardMaterial({
color: 0x140a06, emissive: 0xff3a08, emissiveIntensity: 1.5, roughness: 1, flatShading: true
}));
/* base glow */
{
const g = new THREE.Sprite(new THREE.SpriteMaterial({
map: glowTex, color: 0xffa14a, transparent: true, opacity: 0.9,
blending: THREE.AdditiveBlending, depthWrite: false
}));
g.position.set(0, 0.45, 0);
g.scale.setScalar(4.2);
scene.add(g);
scene.userData.fireGlow = g;
}
/* animated flames */
const flameUniformsProto = () => ({
uTime: { value: 0 },
uPhase: { value: rnd(0, 6.28) },
uHeight: { value: 1 },
uHot: { value: new THREE.Color(0xfff0b0) },
uMid: { value: new THREE.Color(0xff7c16) },
uCool: { value: new THREE.Color(0x8f1a04) }
});
const flameMat = new THREE.ShaderMaterial({
uniforms: flameUniformsProto(),
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
side: THREE.DoubleSide,
vertexShader: /* glsl */`
uniform float uTime, uPhase, uHeight;
varying float vH;
void main(){
float h = clamp((position.y + uHeight * 0.5) / uHeight, 0.0, 1.0);
vH = h;
vec3 p = position;
float sway = sin(uTime * 2.3 + uPhase + h * 5.2) * 0.16 * h
+ sin(uTime * 6.1 + uPhase * 2.1 + h * 10.0) * 0.06 * h;
p.x += sway;
p.z += cos(uTime * 2.8 + uPhase * 1.4 + h * 4.6) * 0.14 * h;
p.xz *= 1.0 - h * 0.22;
p.y += sin(uTime * 5.0 + uPhase + h * 3.0) * 0.04 * h;
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}`,
fragmentShader: /* glsl */`
uniform float uTime, uPhase;
uniform vec3 uHot, uMid, uCool;
varying float vH;
void main(){
vec3 c = mix(uHot, uMid, smoothstep(0.02, 0.45, vH));
c = mix(c, uCool, smoothstep(0.45, 1.0, vH));
float flick = 0.82 + 0.18 * sin(uTime * 13.0 + uPhase * 3.0) * sin(uTime * 7.3 + uPhase);
float a = smoothstep(0.0, 0.10, vH) * (1.0 - smoothstep(0.55, 1.0, vH));
gl_FragColor = vec4(c * flick, a * 0.95);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`
});
const flames = [];
const flameSpecs = [
{ r: 0.40, h: 1.55, x: 0.00, z: 0.00, ph: 0.0 },
{ r: 0.28, h: 1.95, x: 0.16, z: 0.10, ph: 1.9 },
{ r: 0.24, h: 1.25, x: -0.22, z: 0.14, ph: 3.4 },
{ r: 0.20, h: 1.00, x: 0.10, z: -0.24, ph: 5.1 },
{ r: 0.15, h: 0.78, x: -0.12, z: -0.16, ph: 2.6 }
];
for (const s of flameSpecs) {
const mat = flameMat.clone();
mat.uniforms = THREE.UniformsUtils.clone(flameMat.uniforms);
mat.uniforms.uPhase.value = s.ph;
mat.uniforms.uHeight.value = s.h;
const m = new THREE.Mesh(new THREE.ConeGeometry(s.r, s.h, 6, 3), mat);
m.position.set(s.x, s.h * 0.5, s.z);
m.renderOrder = 4;
scene.add(m);
flames.push({ mesh: m, mat, spec: s });
}
/* ------------------------------------------------------------------ *
* Sparks (particle system)
* ------------------------------------------------------------------ */
const sparkMat = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uSize: { value: 150 }
},
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
vertexShader: /* glsl */`
uniform float uTime, uSize;
attribute float aSeed;
attribute float aSpeed;
attribute vec3 aOffset;
varying float vLife;
varying float vSeed;
void main(){
float life = fract(uTime * aSpeed + aSeed);
vLife = life;
vSeed = aSeed;
vec3 p = position + aOffset * 0.25;
float w = life * life;
p.y += life * 5.2;
p.x += sin(aSeed * 47.0 + life * 7.0) * 0.34 * w + aOffset.x * life * 1.4;
p.z += cos(aSeed * 29.0 + life * 6.0) * 0.34 * w + aOffset.z * life * 1.4;
vec4 mv = modelViewMatrix * vec4(p, 1.0);
gl_PointSize = uSize * (0.35 + 0.65 * (1.0 - life)) * (1.0 / max(0.001, -mv.z));
gl_Position = projectionMatrix * mv;
}`,
fragmentShader: /* glsl */`
varying float vLife;
varying float vSeed;
void main(){
vec2 c = gl_PointCoord - 0.5;
float d = length(c);
float a = smoothstep(0.5, 0.03, d);
vec3 col = mix(vec3(1.0, 0.95, 0.62), vec3(1.0, 0.45, 0.08), smoothstep(0.0, 0.55, vLife));
col = mix(col, vec3(0.55, 0.10, 0.02), smoothstep(0.55, 1.0, vLife));
float fade = 1.0 - vLife;
gl_FragColor = vec4(col, a * fade);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`
});
{
const N = 220;
const pos = new Float32Array(N * 3);
const seed = new Float32Array(N);
const speed = new Float32Array(N);
const off = new Float32Array(N * 3);
for (let i = 0; i < N; i++) {
const a = rnd(0, Math.PI * 2), r = Math.sqrt(Math.random()) * 0.55;
pos[i * 3] = Math.cos(a) * r;
pos[i * 3 + 1] = rnd(0.15, 0.5);
pos[i * 3 + 2] = Math.sin(a) * r;
seed[i] = Math.random();
speed[i] = rnd(0.16, 0.44);
off[i * 3] = rnd(-1, 1); off[i * 3 + 1] = rnd(-1, 1); off[i * 3 + 2] = rnd(-1, 1);
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
g.setAttribute('aSeed', new THREE.BufferAttribute(seed, 1));
g.setAttribute('aSpeed', new THREE.BufferAttribute(speed, 1));
g.setAttribute('aOffset', new THREE.BufferAttribute(off, 3));
const p = new THREE.Points(g, sparkMat);
p.frustumCulled = false;
scene.add(p);
}
/* fireflies drifting over the clearing */
{
const N = 46;
const pos = new Float32Array(N * 3);
const seed = new Float32Array(N);
for (let i = 0; i < N; i++) {
const a = rnd(0, Math.PI * 2), r = rnd(4, 17);
pos[i * 3] = Math.cos(a) * r;
pos[i * 3 + 1] = rnd(0.6, 3.4);
pos[i * 3 + 2] = Math.sin(a) * r;
seed[i] = Math.random();
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
g.setAttribute('aSeed', new THREE.BufferAttribute(seed, 1));
const m = new THREE.ShaderMaterial({
uniforms: { uTime: { value: 0 }, uSize: { value: 90 } },
transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
vertexShader: /* glsl */`
uniform float uTime, uSize;
attribute float aSeed;
varying float vF;
void main(){
vec3 p = position;
p.x += sin(uTime * 0.45 + aSeed * 30.0) * 1.5;
p.z += cos(uTime * 0.37 + aSeed * 21.0) * 1.5;
p.y += sin(uTime * 0.6 + aSeed * 17.0) * 0.5;
vF = 0.35 + 0.65 * pow(abs(sin(uTime * 1.7 + aSeed * 44.0)), 3.0);
vec4 mv = modelViewMatrix * vec4(p, 1.0);
gl_PointSize = uSize * (1.0 / max(0.001, -mv.z));
gl_Position = projectionMatrix * mv;
}`,
fragmentShader: /* glsl */`
varying float vF;
void main(){
vec2 c = gl_PointCoord - 0.5;
float d = length(c);
float a = smoothstep(0.5, 0.02, d);
gl_FragColor = vec4(vec3(0.62, 1.0, 0.42), a * vF);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`
});
const p = new THREE.Points(g, m);
p.frustumCulled = false;
scene.add(p);
scene.userData.fireflies = m;
}
/* smoke */
const smokes = [];
for (let i = 0; i < 9; i++) {
const s = new THREE.Sprite(new THREE.SpriteMaterial({
map: smokeTex, color: 0x6a6a78, transparent: true, opacity: 0,
depthWrite: false
}));
s.userData.life = Math.random();
s.scale.setScalar(1.4);
scene.add(s);
smokes.push(s);
}
/* ------------------------------------------------------------------ *
* Animation loop
* ------------------------------------------------------------------ */
const clock = new THREE.Clock();
let firstFrame = true;
function animate() {
requestAnimationFrame(animate);
const t = clock.getElapsedTime();
const dt = Math.min(clock.getDelta ? 0.05 : 0.05, 0.05);
/* flames */
for (const f of flames) {
f.mat.uniforms.uTime.value = t;
const pulse = 1 + 0.10 * Math.sin(t * 5.5 + f.spec.ph) + 0.05 * Math.sin(t * 11.0 + f.spec.ph * 2.0);
f.mesh.scale.set(pulse, 1 + 0.16 * Math.sin(t * 6.2 + f.spec.ph * 1.7), pulse);
f.mesh.rotation.y = t * 0.35 + f.spec.ph;
}
/* flickering fire light */
const flick = 1
+ 0.13 * Math.sin(t * 9.4)
+ 0.08 * Math.sin(t * 23.7 + 1.1)
+ 0.05 * Math.sin(t * 3.3 + 0.4)
+ 0.04 * Math.sin(t * 41.0);
fireLight.intensity = 46 * flick;
fireLight.position.y = 1.02 + 0.07 * Math.sin(t * 7.0);
bounce.intensity = 6 * flick;
emberMat.emissiveIntensity = 2.0 + 0.9 * flick;
if (scene.userData.fireGlow) {
scene.userData.fireGlow.material.opacity = 0.78 + 0.16 * flick;
scene.userData.fireGlow.scale.setScalar(4.0 + 0.35 * flick);
}
/* sparks + fireflies */
sparkMat.uniforms.uTime.value = t;
if (scene.userData.fireflies) scene.userData.fireflies.uniforms.uTime.value = t;
/* stars slowly drift */
if (scene.userData.stars) scene.userData.stars.rotation.y = t * 0.006;
/* smoke */
for (const s of smokes) {
s.userData.life += dt * 0.22;
if (s.userData.life > 1) s.userData.life -= 1;
const l = s.userData.life;
s.position.set(Math.sin(l * 3.1) * (0.3 + l * 1.6), 1.1 + l * 5.2, Math.cos(l * 2.4) * (0.3 + l * 1.5));
s.scale.setScalar(1.0 + l * 4.2);
s.material.opacity = Math.sin(l * Math.PI) * 0.22;
}
/* keep the camera above the ground */
if (camera.position.y < 0.9) camera.position.y = 0.9;
controls.update();
renderer.render(scene, camera);
if (firstFrame) {
firstFrame = false;
const ld = document.getElementById('loader');
ld.classList.add('hide');
setTimeout(() => ld.remove(), 900);
}
}
animate();
/* ------------------------------------------------------------------ *
* Resize
* ------------------------------------------------------------------ */
function onResize() {
const w = window.innerWidth, h = window.innerHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.75));
renderer.setSize(w, h);
sparkMat.uniforms.uSize.value = 150 * (h / 800);
}
window.addEventListener('resize', onResize);
onResize();
</script>
</body>
</html>Reasoning trace (11,798 tokens)
Let me build a single HTML file with Three.js r170 via import map.
Key elements:
- Ground: low-poly terrain (plane with vertex displacement, flat shading)
- Rocks: low-poly icosahedron/dodecahedron with random scaling
- Logs: cylinders around fire
- Trees: cone + cylinder trunk, low-poly, at least 30, placed randomly on a ring outside clearing radius
- Campfire: animated flames — low-poly cone meshes with vertex animation / shader, warm point light flickering, casts shadows
- Sparks: Points particle system rising
- Night sky: large sphere with stars points + moon (sprite/mesh), fog
- OrbitControls with limits (maxPolarAngle, min/max distance, target y)
- Renderer fills window, resize handling
Let me write it carefully.
Details:
Scene setup:
```js
const renderer = new THREE.WebGLRenderer({antialias:true});
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio,2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
```
Fog: `scene.fog = new THREE.FogExp2(0x0a0e1a, 0.03)` — dark blue. But fire light is point light; fog applies to it too. Use Fog (linear) with near 15 far 90? FogExp2 fine. Let's use THREE.Fog(0x0b1020, 18, 95).
Sky: large sphere radius 300, BackSide, with gradient material via shader or vertex colors. Simpler: custom ShaderMaterial with vertical gradient from deep blue to near-black. Since fog would affect it... set material fog:false for sky.
Stars: THREE.Points with 1500 random positions on sphere radius 280, with sizes, using PointsMaterial with size attenuation false, and fog false. Add slight twinkle by animating material opacity? Could vary colors.
Moon: a low-poly sphere (icosahedron detail 1) at a position, MeshBasicMaterial white-ish, plus a glow sprite (canvas texture radial gradient) — but canvas texture is fine (no external file). Let's make a glow using a ShaderMaterial on a plane facing camera (sprite). THREE.Sprite with a canvas-generated radial gradient texture works well.
Ground: PlaneGeometry(160,160, 40,40) rotated, displaced with noise-ish function (sum of sines), vertex colors darker green/blue, flatShading. Need the center flattening so fire sits on ground. Displacement function: h = (sin(x*0.15)*cos(z*0.12)*1.2 + ...), then multiply by smoothstep of radius to flatten near center.
Also a circular "dirt/ash" patch around the fire — a circle geometry slightly above ground, brownish.
Rocks: IcosahedronGeometry(1,0) with random rotation and scale, dark gray, flatShading, castShadow/receiveShadow. Place ~14 rocks around clearing radius 4-18, avoid fire radius < 3.
Logs: cylinders lying down around fire at radius ~2.2, rotated; some stacked in a teepee over the fire (3-4 logs leaning). Use CylinderGeometry(0.25,0.3,2.2,6) with bark color, flatShading. Also cut ends.
Trees: cone trees (2 cones stacked) + trunk cylinder, flat shading, dark green. Place 40 trees randomly at radius 14-60, avoiding a clearing radius of 12. Randomize height, color variation. Add some distant trees as silhouettes. Also add pine trees made of a few stacked cones with 5-6 radial segments (low poly).
Campfire flames: build flame as several cone meshes (low-poly, ConeGeometry(1, 2, 5, 1)) with vertex-shader-animated shader material that is additive blended, colors from orange bottom to yellow top. Use a ShaderMaterial with time uniform to animate vertex positions (wavy). Multiple flame meshes with different scales/phases.
Simplest robust approach: a custom ShaderMaterial with `uniform float uTime;` and in vertex shader offset x/z by sin(y*3 + time*6 + phase). Use additive blending, depthWrite false. Colors: gradient via varying vY based on local position y.
Point light: warm color 0xffaa44, intensity flickering via sin noise, distance ~30, decay 2, castShadow true with shadow map 1024, bias.
Sparks: BufferGeometry points with positions updated each frame; each spark rises, random lateral drift, fades. Use PointsMaterial with size attenuation and vertex colors alpha? PointsMaterial doesn't support per-point alpha, but supports vertexColors with alpha if we set color attribute with itemSize 4? In three r170, PointsMaterial vertexColors uses vColor as vec3 (from color attribute itemSize 3) — actually with itemSize 4 it might work since it's `attribute vec4 color` if `USE_COLOR_ALPHA`. Three supports vertex color alpha if the color attribute has 4 components (defines USE_COLOR_ALPHA). Yes — in WebGLPrograms, vertexAlphas is set when material.vertexColors && geometry.attributes.color.itemSize === 4. So use itemSize 4 color attribute with PointsMaterial({vertexColors:true, transparent:true, blending:AdditiveBlending, depthWrite:false, size, map: spark texture}).
Alternatively custom shader for sparks for nicer look. Let's use a small ShaderMaterial for sparks: per-point attributes aSeed, size, life; compute positions in shader from time with fract-based life cycle. That is elegant and cheap. But fading alpha with additive blending: fade by using alpha multiplied.
Let's do CPU-updated sparks — simple and clear, ~120 sparks. Actually shader is smoother and avoids per-frame allocation. I'll do a shader-based approach:
attributes: aOffset (vec3 start), aSeed (float), aSpeed (float)
position attribute base = start position (same for all sparks), but need per-point random offsets. Use aStart attribute.
Vertex shader:
```
float life = fract(uTime * aSpeed + aSeed);
vec3 p = position; // base position at fire
p.y += life * 4.5;
p.x += sin(aSeed*30.0 + life*6.0) * life * 0.6;
p.z += cos(aSeed*17.0 + life*5.0) * life * 0.6;
vAlpha = (1.0-life) * ...
gl_PointSize = (1.0 - life*0.5) * 12.0 * (300.0/ -mvPosition.z)
```
Fragment: circular soft dot, color mix yellow->orange->red by life.
Good.
Also embers glow ring at base of fire.
Fire: also a bed of embers: small icosahedron rocks glowing (emissive orange) around base.
Also add a subtle glow plane/sprite at fire base.
OrbitControls: import from three/addons/controls/OrbitControls.js. Set minDistance 4, maxDistance 60, maxPolarAngle = Math.PI*0.49 (so camera stays above ground), minPolarAngle 0.1, enableDamping true, target (0, 1.2, 0). Also clamp camera.position.y above ground in the animate loop just in case (camera.position.y = max(camera.position.y, 0.6)).
Also add a "Click or press a key to start" overlay? The requirement from the system prompt about self-playing demo mode applies to games. This is a scene, not a game, but OrbitControls needs pointer — that works. However the instructions say the iframe only receives keyboard events after user clicks it. Since this isn't a game, no keyboard needed. But maybe add a gentle auto-orbit that starts immediately and stops on user interaction? A slow auto-rotate would make the screenshot show a nice view. Let's implement: controls.autoRotate = true with slow speed initially; on pointerdown, disable autoRotate. That gives motion on first paint. Actually autoRotate requires controls.update() each frame — fine.
Hmm, but autoRotate may make it feel less "judged in action". It's fine — motion is good.
Add overlay text with title "Night Campfire" and hint "Drag to orbit · Scroll to zoom". Fade out the hint after a while.
Let me also add a camera intro: start slightly far and ease in? Keep simple: start at (9, 4.2, 9) looking at (0,1.3,0).
Also need to handle resize: update camera aspect, renderer size.
Let me write the ground noise: function terrainHeight(x,z):
```
function h(x,z){
return Math.sin(x*0.13)*Math.cos(z*0.11)*1.4 + Math.sin(x*0.31+1.7)*0.45 + Math.cos(z*0.27-0.6)*0.4;
}
```
Then flatten: factor = smoothstep(6, 20, dist) → ground = h * factor. So near center it's flat at 0.
Ground plane 200x200 with 60 segments → 3600 verts, fine. Beyond that use a big dark circle / or just the fog hides edges. Use radius large: plane 300x300 with 80 segments (6400 verts) ok.
Trees placed on ground: y = terrainHeight with the same flattening function (dist-based), so tree base sits on ground.
Let's ensure trees aren't inside the clearing: radius from 13 to 55, with random angle. Ensure count >= 30; I'll place 48. Also add a few dead/bare trees for variety.
Low-poly trees: build a helper function makeTree(rng) returning a group. Trunk: CylinderGeometry(0.16,0.28,2.2,6) brown. Foliage: 2-3 cones ConeGeometry(r, h, 7) stacked, dark green with slight hue variation. Cast shadow (shadow map far enough). 48 trees casting shadows might be heavy but with one shadow-casting point light it's a lot of shadow rendering. Point light shadow renders 6 faces × all objects. Could be heavy but ok at 1024. To optimize: trees far away don't need to cast into the point light shadow. Since point light distance ~26, trees beyond 26 aren't lit anyway. But shadow rendering still processes all objects in the frustum. With 48 trees × (4 meshes each) = 192 meshes × 6 faces = ~1150 draw calls per frame in shadow pass. That's potentially slow but might be fine at 1280x800 with simple geometry. To be safe: set castShadow only for trees within ~26 units, and only for near ones. Also use a directional light (moonlight) with a shadow map for tree shadows on the ground — directional shadow is 1 pass. That's better: moon directional light casting long shadows; plus the fire point light casts shadows for nearby objects only.
Plan:
- Hemisphere light (dim blue) for ambient.
- Directional "moonlight" from moon direction, color 0x9fb6ff, intensity 0.35, castShadow with shadow camera covering ~60 units, mapSize 2048. Cast shadows from trees.
- Point light at fire, warm, castShadow, shadow.mapSize 1024, shadow camera near 0.1 far 30, bias -0.005. Only nearby objects cast (rocks, logs, fire pit, a few trees within 25). Far trees castShadow=false to save perf. Actually for the directional light, all trees casting shadows is fine and looks great.
Careful: point light shadow with all trees casting → set castShadow on trees only if dist<26.
Merging trees? Not needed.
Also add distant tree silhouette ring: maybe just rely on the same trees plus fog.
Let me also add a "fire glow" sprite on the ground: a circle mesh with additive radial gradient texture, giving warm pool of light on ground.
Ground color: dark green-blue grass, with vertex color variation.
Also add some grass tufts? Maybe low-poly small cones/triangles near the clearing — could add a few for detail. Keep moderate: add ~120 small grass blades (crossed planes) with dark green, placed in clearing. Cheap.
Let's produce the spark shader carefully with three r170 ShaderMaterial.
Also flames: ShaderMaterial with additive blending, transparent, depthWrite false, side DoubleSide.
Flame geometry: For each of 5 flame layers: ConeGeometry(radius, height, 6, 4, true) open-ended? Use closed cone. Vertex shader displaces with time. Fragment shader: color gradient based on local y (varying uv.y or position.y normalized) — use uv from ConeGeometry: uv.y goes 0 at bottom to 1 at top. Use vUv.y.
Color: bottom (y=0) white-yellow (1.0, 0.95, 0.6), mid orange (1.0,0.5,0.1), top red-dark (0.6,0.12,0.02). Use mix.
Alpha: fade near edges? For low-poly look, alpha = 1 * smoothstep near bottom, fade at top. Since additive, ok.
Vertex displacement:
```
float t = uTime * 3.0 + aPhase;
transformed.x += sin(transformed.y*2.5 + t*1.5) * 0.12 * (transformed.y/height)
```
Use normalized height h = position.y / uHeight (cone centered, so y in [-h/2, h/2]); use (position.y + uHeight*0.5)/uHeight as t01.
Add per-vertex phase attribute? Use uPhase uniform per mesh.
Flames animate also scale pulse.
Also the "core" flame bright.
Moon: Mesh sphere with MeshBasicMaterial color 0xdfe6ff, plus glow sprite. Position at direction (e.g., (-60, 45, -70)) normalized times 200.
Stars: Points on sphere radius 240 with random sizes; PointsMaterial size 1.6 sizeAttenuation false, transparent, opacity 0.9, vertexColors for slight color variation (blue-white to warm). Also a subtle twinkle by rotating the whole star field slowly? Fine — rotate stars slowly.
Sky dome: SphereGeometry(300, 24, 16), BackSide, ShaderMaterial with gradient and slight noise; fog:false, depthWrite false, renderOrder -1.
Fog: scene.fog = new THREE.Fog(0x0a1020, 20, 120). Sky must ignore fog → set material.fog = false.
Trees beyond fog far end fade to fog color which should match sky bottom color.
Let me pick colors:
- sky top: 0x04070f, horizon: 0x0d1626
- fog color 0x0c1422
Now write the code.
Also the requirement: "The campfire has animated flames and a warm point light that flickers and casts shadows." ✓
Add a second warm light? Maybe small orange fill without shadow.
Let me write HTML.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Night Campfire — Low Poly Forest</title>
<style> ... </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="app"></div>
<div id="hud">...</div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
...
</script>
</body>
</html>
```
HUD: title top-left "NIGHT CAMPFIRE", subtitle, and hint bottom "Drag to orbit · Scroll to zoom". Also a loading overlay that fades.
Let's write the terrain height function used both for mesh and object placement.
```js
function rawH(x,z){
return Math.sin(x*0.09)*Math.cos(z*0.08)*1.6 + Math.sin(x*0.23+1.3)*0.5 + Math.cos(z*0.19-0.4)*0.45 + Math.sin((x+z)*0.05)*0.9;
}
function groundY(x,z){
const d = Math.hypot(x,z);
const f = THREE.MathUtils.smoothstep(d, 7, 26); // 0 inside, 1 outside
return rawH(x,z) * f;
}
```
Wait THREE.MathUtils.smoothstep(x, min, max) returns 0 if x<=min, 1 if x>=max. Good.
Ground mesh: PlaneGeometry(320,320, 90,90) → 8281 verts, 16k tris. Fine.
Vertex colors: base dark green 0x1d2a1c-ish with variation; mix with brown near center? The dirt patch handled separately.
Let me compute color per vertex: c = base color lerped by noise. Use THREE.Color lerp between two greens; multiply by slight random.
Flat shading: use material.flatShading = true with vertexColors — flat shading with vertex colors works (colors interpolated per-vertex then flat normals). Good.
Dirt patch: CircleGeometry(3.4, 24) rotated flat at y=0.02, color 0x2a2018, receiveShadow. Maybe slight irregularity: use a custom shape — skip, circle is fine but "low-poly" - use 12 segments for a faceted look. Use CircleGeometry(3.2, 14).
Stone ring: 9 rocks arranged in a ring radius 1.5 around fire, small, gray.
Logs: 4 logs around fire forming a square at radius ~1.6 lying horizontally, plus 3 leaning logs in a teepee over the fire.
Fire pit embers: cluster of small icosahedrons with emissive material.
Let me now write the whole script.
```js
const container = document.getElementById('app');
const scene = new THREE.Scene();
scene.fog = new THREE.Fog(0x0b1322, 16, 95);
const camera = new THREE.PerspectiveCamera(50, innerWidth/innerHeight, 0.1, 600);
camera.position.set(8.5, 4.5, 9.5);
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.15;
renderer.outputColorSpace = THREE.SRGBColorSpace;
container.appendChild(renderer.domElement);
```
Note: r170 outputColorSpace default is SRGB already; setting explicitly fine.
Sky dome shader:
```js
const skyGeo = new THREE.SphereGeometry(280, 32, 16);
const skyMat = new THREE.ShaderMaterial({
side: THREE.BackSide, depthWrite:false, fog:false,
uniforms:{},
vertexShader:`varying vec3 vPos; void main(){ vPos = position; gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0);} `,
fragmentShader:`varying vec3 vPos; void main(){
float h = normalize(vPos).y;
vec3 top = vec3(0.012,0.018,0.045);
vec3 mid = vec3(0.03,0.05,0.10);
vec3 horizon = vec3(0.07,0.09,0.14);
vec3 c = mix(horizon, mid, smoothstep(-0.05,0.35,h));
c = mix(c, top, smoothstep(0.2,0.9,h));
gl_FragColor = vec4(c,1.0);
}`
});
```
Note: since we use ACES tone mapping and sRGB output, colors specified linear get tone mapped. ShaderMaterial output is not tone mapped unless we add tonemapping chunk. For consistency, add `#include <tonemapping_fragment>` and `#include <colorspace_fragment>` to fragment shader. Let's do that for the sky and flame shaders to keep them consistent. Actually for additive flame, tonemapping is desirable.
Fragment shader ending:
```
gl_FragColor = vec4(c, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
```
These chunks operate on gl_FragColor. In r170, tonemapping_fragment uses `gl_FragColor.rgb = toneMapping( gl_FragColor.rgb );` guarded by TONE_MAPPING define — ShaderMaterial doesn't automatically get the tone mapping defines... Actually WebGLProgram adds tone mapping defines to all materials? In three, `getToneMappingFunction` is included in the program for materials when `parameters.toneMapping !== NoToneMapping`. For ShaderMaterial, the prefix includes `parameters.toneMapping !== NoToneMapping ? '#define TONE_MAPPING' : ''` plus the function. Yes — in WebGLProgram.js, the fragment prefix for non-raw shader materials includes the tone mapping function and the define, based on renderer.toneMapping (via parameters.toneMapping which is set to material.toneMapped ? renderer.toneMapping : NoToneMapping). ShaderMaterial has toneMapped = true by default. So including the chunk works.
Similarly colorspace_fragment uses `linearToOutputTexel` which is included in prefix. Good.
But careful: for additive blending, output colorspace conversion applies — fine.
Stars: Points with sizeAttenuation false, size ~1.8, and since we use a ShaderMaterial? Use PointsMaterial with vertexColors and fog:false, transparent, opacity 0.9, depthWrite false. Colors are sRGB-ish values in the attribute; three converts vertex colors as linear? Vertex colors are assumed linear in r152+? Actually THREE.Color.setHex converts from sRGB to linear by default (with color management enabled). When writing to attribute directly, values are used as-is in linear space. So for stars I should set colors in linear-ish values (small). Use new THREE.Color().setHSL(...) then .convertSRGBToLinear? Simplest: use color values around 0.6-1.0 linear, they'll look fine/bright.
Let's create stars: 1400 points on sphere radius 250, upper hemisphere mostly (y from -0.1 to 1).
Moon: mesh icosahedron radius 6 at distance 220 in direction (-0.5, 0.45, -0.75). MeshBasicMaterial color 0xf2f0e0 (basic material color is converted from sRGB hex → linear automatically by Color.setHex with color management). Then tone mapping will darken a bit; acceptable. Use emissive-like: MeshBasicMaterial, toneMapped default true; ACES will make it slightly gray. Fine.
Glow sprite: canvas radial gradient texture; THREE.SpriteMaterial with map, color 0x9fb8ff, blending Additive, transparent, depthWrite false, fog false. Scale 45.
Also a second larger faint halo.
Point light:
```js
const fireLight = new THREE.PointLight(0xff8a2a, 12, 40, 2);
fireLight.position.set(0, 1.1, 0);
fireLight.castShadow = true;
fireLight.shadow.mapSize.set(1024,1024);
fireLight.shadow.camera.near = 0.2;
fireLight.shadow.camera.far = 40;
fireLight.shadow.bias = -0.004;
fireLight.shadow.radius = 3;
```
With physically correct lighting (r155+), intensity in candela; a value ~ 20-60 for a campfire. PointLight intensity units: irradiance = intensity / distance^2. At distance 3, intensity 30 → 3.3. Let's set base intensity 40 and flicker 30-55. Tune.
Also add ambient hemisphere: new THREE.HemisphereLight(0x2a3a5c, 0x0a0f0a, 0.35).
Moonlight directional: color 0x9ab4ff intensity 0.6, position from moon direction, castShadow with shadow.camera ortho -40..40, mapSize 2048, bias -0.0008, normalBias 0.02.
Trees: create with shared geometries to save memory? Different sizes need scaling; use unit geometries and scale the group. Good: create geometries once:
- trunkGeo = CylinderGeometry(0.5, 0.7, 2, 6) (unit, scaled)
- coneGeo1 = ConeGeometry(1, 1, 7)
Then group.scale.set(w, h, w).
Tree builder:
```js
function makeTree(rand){
const g = new THREE.Group();
const trunkMat = new THREE.MeshStandardMaterial({color: 0x2a1d14, roughness:1, flatShading:true});
...
}
```
Per-tree materials would be many materials (48*4). Acceptable but let's share materials in small palette (3 variants) to reduce state changes.
Materials palette:
- trunk: 0x241a12
- foliage: [0x14251a, 0x1b3020, 0x101d16]
Tree structure: trunk cylinder (0.35 radius, height 2.4), foliage: 3 cones stacked: cone1 radius 1.5 height 2.2 at y 2.2, cone2 radius 1.1 h 1.9 at y 3.3, cone3 radius 0.7 h 1.6 at y 4.4. Total height ~5.2. Scale group by s in [0.7, 1.6].
Also alternate "snow pine"? no.
Add a couple of bare/dead trees with just trunk + branches for variety.
Placement:
```js
const treeCount = 46;
for(i...){
const ang = rand*Math.PI*2;
const r = 13 + Math.pow(rand,0.6)*40; // 13..53
const x = Math.cos(ang)*r, z = Math.sin(ang)*r;
// ground y
}
```
Ensure no tree within 12 of center. Also vary rotation.
Rocks: 16 rocks scattered radius 4-22, plus 9 in fire ring. Rock geometry: IcosahedronGeometry(1,0) with per-vertex jitter to be more irregular, flat shading, color 0x3a3f44 with variation. Also a couple of boulders bigger.
Logs around fire: CylinderGeometry(0.22,0.26,2.4,7) rotated to lie horizontal (rotation.z = PI/2) then rotate around Y for direction; positions at radius 1.7. Plus teepee logs: 3 logs leaning against each other over the fire: length 2.6, positioned tilted so bottoms at radius 0.9 and tops meeting near y=1.6.
Compute: for i in 0..2: angle = i*2PI/3; log positioned at (cos*0.95, 0.55, sin*0.95), rotated so its axis points toward center-up: use lookAt-ish. Easiest: create cylinder along Y, then rotate: set rotation.x/z to tilt. For a log leaning from base at radius 0.95 toward apex at (0,1.5,0): direction vector d = apex - base normalized. Use quaternion setFromUnitVectors(new Vector3(0,1,0), d). Then position at midpoint.
Log geometry: CylinderGeometry(0.18,0.22, len, 7) where len = distance between base and apex ≈ sqrt(0.95²+1.5²)=1.77. Let's make logs longer, extending past: base at radius 1.1 on ground y=0.18, apex at y 1.6 → len 1.85.
Also add a burning log on the ground inside the fire ring (partially charred, emissive at one end).
Sparks: 140 points.
Also add small "ember" glow at base: additive sprite.
Grass tufts: 200 small triangles near clearing (radius 2-14) — use InstancedMesh? Simpler: BufferGeometry with many quads? For low-poly, use a single merged geometry of triangles: create positions array of random blades (each blade a triangle). 300 blades * 3 verts = 900 verts. Material MeshStandardMaterial? Use MeshBasicMaterial dark green with vertex colors, or MeshLambert. Use MeshStandardMaterial({color:0x1e3320, side:DoubleSide, flatShading}) — but no lighting variation... standard material gets lit by fire → nice. Use DoubleSide.
Let's build grass via BufferGeometry with positions and normals computed (computeVertexNormals).
OK, and the fire flame meshes.
Flame shader:
```js
const flameUniforms = { uTime:{value:0}, uSpeed:{value:1}, uPhase:{value:0}, uHeight:{value:2.0}, uHot:{value:new THREE.Color(0xfff2a8)}, uMid:{value:new THREE.Color(0xff7a18)}, uCool:{value:new THREE.Color(0x9c1d05)} };
```
Vertex:
```glsl
uniform float uTime, uPhase, uHeight;
varying vec2 vUv;
varying float vH;
void main(){
vUv = uv;
float h = (position.y + uHeight*0.5)/uHeight; // 0..1
vH = h;
vec3 p = position;
float sway = sin(uTime*2.4 + uPhase + h*5.0)*0.18*h + sin(uTime*5.1 + uPhase*2.0 + h*9.0)*0.06*h;
p.x += sway;
p.z += cos(uTime*2.9 + uPhase*1.3 + h*4.5)*0.16*h;
p.xz *= 1.0 - h*0.25; // taper
gl_Position = projectionMatrix*modelViewMatrix*vec4(p,1.0);
}
```
Fragment:
```glsl
uniform vec3 uHot,uMid,uCool; uniform float uTime,uPhase;
varying vec2 vUv; varying float vH;
void main(){
vec3 c = mix(uHot, uMid, smoothstep(0.0,0.45,vH));
c = mix(c, uCool, smoothstep(0.45,1.0,vH));
float a = smoothstep(0.0,0.12,vH) * (1.0 - smoothstep(0.6,1.0,vH));
float flick = 0.85 + 0.15*sin(uTime*12.0+uPhase);
gl_FragColor = vec4(c*flick, a);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
```
Hmm with additive blending, alpha multiplies the source color (blending src=SrcAlpha? AdditiveBlending in three: blendSrc = SrcAlphaFactor, blendDst = OneFactor). So alpha does modulate. Good.
Flame cone geometry: ConeGeometry(r, h, 6, 3) centered at origin, y from -h/2 to h/2.
Layers: 5 flames with different radius/height/phase/position offset within the pit, plus base glow cone.
Also a "core" bright small cone.
Fire light position: y ~0.9.
Now animation loop:
```js
let t0 = performance.now();
function animate(){
const now = performance.now();
const t = (now - t0)/1000;
// update uniforms
// flicker light
const flick = 1 + 0.12*Math.sin(t*11.3)+0.08*Math.sin(t*27.7+1.3)+0.06*Math.sin(t*3.1);
fireLight.intensity = baseIntensity * flick;
...
controls.update();
renderer.render(scene,camera);
requestAnimationFrame(animate);
}
```
Also flames scale pulsing.
Add stars rotation slow: stars.rotation.y = t*0.004.
Also add a subtle blue "fireflies"? optional; skip or add few floating motes near the clearing for atmosphere. Could add small glowing dots. Keep it: 40 fireflies as points slowly drifting, greenish. Might be nice but adds complexity; I'll add simple ones with a shader similar to sparks but drifting horizontally. Let's include — nice atmosphere. Actually keep scope manageable; I'll add a simple points system for fireflies with CPU update of 30 points. Eh, I'll include it, cheap.
Also smoke: a few semi-transparent dark cones rising above fire? Could look bad. Add subtle smoke puffs: 8 sprites with low opacity drifting up and fading. Use a canvas soft blob texture, color 0x2a2a2a, opacity fading. Let's add — adds realism.
Let me limit total work but write it all carefully.
Canvas textures: create a radial gradient canvas for glow (used by moon halo, fire glow, sparks? sparks use shader). For smoke use a soft blob canvas.
Now HUD overlay styling: top-left title with letter-spacing, small caps; bottom center hint. Use CSS with pointer-events none.
Also responsive: on narrow screens reduce pixel ratio, adjust camera FOV maybe. Fine.
Let's also add a "quality" auto: renderer.setPixelRatio(Math.min(devicePixelRatio, 2)).
Resize handler: window.addEventListener('resize', onResize) → camera.aspect, updateProjectionMatrix, renderer.setSize.
Also handle visibility of the "click to start" message? The system says for games. This is a scene; I'll show a hint "Drag to orbit · Scroll to zoom" and it disappears after first interaction. Not blocking.
Now, potential perf concern: point light shadow with 6 cube faces at 1024 rendering all shadow casters. Trees beyond 26 units: set castShadow = false to cut cost; but trees within 26 that also cast into moon dir shadow — fine, keep castShadow true for those (they cast in both maps). Let's set tree.castShadow = (dist < 24) — and for far trees castShadow=false entirely. Ground receiveShadow true. Rocks/logs castShadow true.
Moon directional shadow: covers area around origin (ortho -45..45) → 2048 map.
Total shadow passes: 1 directional + 6 point faces. With limited casters near the fire (rocks, logs, ground), the point shadow pass is cheap-ish but still renders the whole scene per face (all objects). three renders all objects in shadow camera frustum. Trees far away are outside point light's shadow camera far=40 but the shadow camera is a cube of far=40 around light, so trees within 40 units are included! That means point light shadow renders ~ all trees 6 times. Hmm. Set pointLight.shadow.camera.far = 26 → only objects within 26 units are in the shadow frustum. Trees beyond 26 won't render into point shadow map. Trees within 26 (maybe 10 trees) render 6 times. Acceptable.
Alternatively reduce to 512 for the point shadow. Use 1024 with far 26.
Let's write final code. I'll be careful with shader includes.
One more: for the sky dome, since fog is enabled, set skyMat.fog = false (ShaderMaterial has fog property default false; for ShaderMaterial fog default is false unless set true). Actually ShaderMaterial's `fog` default is false. Good. But we must make sure the sky renders behind everything: depthWrite false and renderOrder -1; also material.depthTest false? With radius 280 and camera far 600, depth is fine, so keep depth test on but depthWrite false, renderOrder = -1. Also the sky must be positioned at camera? If the camera orbits within radius 60 of origin and sky sphere radius 280 centered at origin, camera stays inside. Good.
Fog far 95 means stars (radius 250) — PointsMaterial has fog default true → stars would be fully fogged! Must set fog:false for stars material. Yes.
Moon mesh: MeshBasicMaterial fog default true → moon would be fogged to invisibility. Set fog:false. Same for halo sprite (SpriteMaterial fog true default) → set fog false.
Good catch.
Ground extends 320/2=160 radius, fog far 95 → ground fades. Camera far 600 fine.
Let me now write everything.
For the "dirt patch", also add a subtle warm glow decal under the fire: CircleGeometry(6) with additive radial texture, orange, opacity 0.5, to fake light pool on the ground (in addition to real point light). Place slightly above ground y=0.03 to avoid z-fighting with dirt patch at 0.02.
Order: dirt circle at y=0.015, glow at y=0.03.
But ground displacement is flat (y=0) within radius 7 so fine.
Also the grass blades: place them in radius 3.5..16, avoid dirt.
Let me write the code now in full.
```js
// ---------- utilities
const rand = (a,b)=>a+Math.random()*(b-a);
```
Tree colors and materials:
```js
const trunkMat = new THREE.MeshStandardMaterial({color:0x2b1e15, roughness:0.95, flatShading:true});
const leafMats = [0x16301d, 0x1c3a24, 0x11241a, 0x22402a].map(c=>new THREE.MeshStandardMaterial({color:c, roughness:1, flatShading:true}));
```
Now assembling trees with shared geometry:
```js
const trunkGeo = new THREE.CylinderGeometry(0.32, 0.5, 2.2, 6, 1);
trunkGeo.translate(0, 1.1, 0);
const coneGeo = new THREE.ConeGeometry(1, 1, 7, 1);
```
Tree group:
```js
function makeTree(scale, leafIdx){
const g = new THREE.Group();
const trunk = new THREE.Mesh(trunkGeo, trunkMat);
g.add(trunk);
const mat = leafMats[leafIdx];
const layers = [[1.55, 2.3, 1.95],[1.15,1.9,3.05],[0.72,1.5,4.0]];
for(const [r,h,y] of layers){
const m = new THREE.Mesh(coneGeo, mat);
m.geometry is shared → need scaling: m.scale.set(r, h, r); m.position.y = y;
g.add(m);
}
g.scale.setScalar(scale);
return g;
}
```
Note coneGeo is unit cone with radius 1 and height 1 centered → scale (r, h, r) gives radius r, height h, center at y=0 → position.y = y means base at y - h/2. Let me define layers with base y: e.g. cone1 base at 1.4 height 2.3 → center at 1.4+1.15=2.55. cone2 base 2.5 h 1.9 → center 3.45. cone3 base 3.4 h 1.5 → center 4.15. So use center positions [2.55, 3.45, 4.15] and heights. Overlapping cones gives classic pine.
Set castShadow per tree.
Dead tree: trunk + 3 branch cylinders tilted.
OK writing final.
Grass geometry:
```js
const grassPositions = [];
for(let i=0;i<420;i++){
const a = Math.random()*Math.PI*2;
const r = 3.2 + Math.pow(Math.random(),0.7)*14;
const x=Math.cos(a)*r, z=Math.sin(a)*r;
const y = groundY(x,z);
const h = 0.25+Math.random()*0.4;
const w = 0.07;
const rot = Math.random()*Math.PI;
const dx = Math.cos(rot)*w, dz=Math.sin(rot)*w;
grassPositions.push(x-dx, y, z-dz, x+dx, y, z+dz, x+Math.cos(rot)*0.15, y+h, z+Math.sin(rot)*0.15);
}
```
Hmm the third vertex offset should be a lean. Fine.
Set geometry attribute position, computeVertexNormals, material MeshStandardMaterial({color:0x24361f, side:DoubleSide, flatShading:true, roughness:1}). But with vertex colors for variation maybe skip.
Grass castShadow false, receiveShadow true.
Rocks: create a few base geometries with jitter:
```js
function rockGeo(seed){
const g = new THREE.IcosahedronGeometry(1,0);
const p = g.attributes.position;
for(let i=0;i<p.count;i++){
const s = 0.75+Math.random()*0.45;
p.setXYZ(i, p.getX(i)*s, p.getY(i)*s*0.8, p.getZ(i)*s);
}
g.computeVertexNormals();
return g;
}
```
Note IcosahedronGeometry is non-indexed? IcosahedronGeometry (PolyhedronGeometry) is non-indexed, so vertices duplicated — jitter per duplicate vertex creates cracks! Since positions are duplicated per face, random scaling per vertex breaks the mesh into separate faces. Need to jitter consistently: use a deterministic function of the original coordinate rounded. Use a hash based on rounded position so duplicates get same value.
```js
function hash3(x,y,z){ return Math.sin(x*12.9898+y*78.233+z*37.719)*43758.5453; }
```
Compute key from rounded coords (to 2 decimals):
```js
const k = Math.round(x*100)+','+Math.round(y*100)+','+Math.round(z*100);
const h = hash from string → deterministic.
```
Simpler: use noise via sin of the coordinate itself (continuous) → duplicates at same position give same value automatically! e.g. s = 0.8 + 0.2*Math.sin(x*7.1+y*3.3+z*5.7). Since duplicates have identical coords, they get identical scaling.
Then computeVertexNormals on non-indexed geometry gives flat normals per face — good (flatShading not even needed but keep).
Rocks material: MeshStandardMaterial({color:0x3c4147, roughness:0.9, flatShading:true}) with a couple of color variants.
Fire ring stones: 9 small rocks radius ~1.35 arranged in circle, rotated randomly, partially sunk.
Now the point light shadow of the fire pit: The stones and logs cast shadows onto the ground — nice.
Ember bed: cluster of ~14 small icosahedrons (scale 0.1-0.25) with MeshStandardMaterial({color:0x221108, emissive:0xff4a10, emissiveIntensity: 2}) inside the fire pit, plus glow sprite.
Let's write the spark system shader:
```js
const SPARK_COUNT = 160;
const sparkGeo = new THREE.BufferGeometry();
const pos = new Float32Array(SPARK_COUNT*3);
const seeds = new Float32Array(SPARK_COUNT);
const speeds = new Float32Array(SPARK_COUNT);
const offs = new Float32Array(SPARK_COUNT*3);
for(...){ pos at random within radius 0.5 near 0; offs random; seeds random; speeds 0.25..0.6 }
attributes: position (base), aSeed, aSpeed, aOffset (vec3)
```
Vertex:
```glsl
uniform float uTime; uniform float uPixelRatio;
attribute float aSeed; attribute float aSpeed; attribute vec3 aOffset;
varying float vLife; varying float vSeed;
void main(){
float life = fract(uTime*aSpeed + aSeed);
vLife = life; vSeed = aSeed;
vec3 p = position + aOffset*0.2;
float rise = life*5.0;
p.y += rise;
float w = life*life;
p.x += sin(aSeed*50.0 + life*8.0)*0.35*w + aOffset.x*life*1.2;
p.z += cos(aSeed*31.0 + life*7.0)*0.35*w + aOffset.z*life*1.2;
vec4 mv = modelViewMatrix*vec4(p,1.0);
gl_PointSize = (10.0*(1.0-life*0.6)) * uPixelRatio * (1.0/ -mv.z) * 30.0;
```
Hmm sizing: use gl_PointSize = size * (scale / -mv.z). With scale = renderer height/2 * something. Let's do: uSize uniform = 60.0 (world size factor): gl_PointSize = uSize * aSize * (1.0 - life*0.5) * (1.0/ -mv.z) where uSize tuned so at distance 10 the point is ~ (60*0.02)=1.2px? Let's define uSize = 120: at -mv.z = 10 → 12px * (1-life). Good. Use uSize uniform 120 * pixelRatio maybe.
Fragment:
```glsl
varying float vLife; varying float vSeed;
void main(){
vec2 c = gl_PointCoord - 0.5;
float d = length(c);
float a = smoothstep(0.5, 0.05, d);
vec3 col = mix(vec3(1.0,0.95,0.6), vec3(1.0,0.45,0.08), smoothstep(0.0,0.6,vLife));
col = mix(col, vec3(0.6,0.12,0.02), smoothstep(0.6,1.0,vLife));
float fade = (1.0 - vLife);
gl_FragColor = vec4(col, a*fade);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
```
Additive blending, depthWrite false, transparent true.
Note: colors in linear space here since we output with colorspace_fragment after tonemapping — correct pipeline (tonemapping expects linear input).
But careful: `#include <tonemapping_fragment>` requires the define; ShaderMaterial default toneMapped=true so it should be present. However, there's a catch: `tonemapping_fragment` chunk in r170:
```
#if defined( TONE_MAPPING )
gl_FragColor.rgb = toneMapping( gl_FragColor.rgb );
#endif
```
Good.
`colorspace_fragment`:
```
gl_FragColor = linearToOutputTexel( gl_FragColor );
```
Good.
Smoke sprites: I'll implement with THREE.Sprite and canvas texture; each sprite updated: y += dt*0.5; scale grows; opacity fades. Use Material per sprite (need individual opacity) — 8 sprites = 8 materials, fine.
Actually simpler: create 8 Sprite with cloned material.
Fireflies: skip? I'll add a small set of Points with a simple shader drifting. Let me just do sparks-like but greenish and slow horizontal drift, placed around the clearing (not at fire). Use a separate geometry with positions around clearing radius 4-14, y between 0.5 and 3, drift via sin in shader.
Ok, time to write. I'll keep code organized.
Also `controls.target.set(0, 1.4, 0)`; maxPolarAngle = Math.PI/2 - 0.02 keeps camera above the target plane at y=1.4... Actually polar angle measured from +Y; maxPolarAngle < PI/2 keeps camera above target's horizontal plane, i.e., above y=1.4. Good. Additionally clamp camera.position.y >= 0.8 in loop.
minDistance 4, maxDistance 70.
Also controls.enablePan = false maybe (keeps camera near clearing) — allow pan but limited? Let's disable pan to keep composition. I'll enable pan false.
Also autoRotate = true, autoRotateSpeed = 0.35, and disable on 'start' event of controls.
Let me write out the final HTML now.
HUD design:
- Top-left: "NIGHTFALL" small label + "Campfire Clearing" title, subtitle "low-poly forest • three.js".
- Bottom-center hint: "drag to orbit · scroll to zoom".
- Add a soft vignette overlay via CSS radial gradient (pointer-events none) for cinematic look.
Loading: show a "loading" div that hides after init (requestAnimationFrame after first render).
Write final code.
One concern: `#include <tonemapping_fragment>` inside a ShaderMaterial used with fog? Not needed.
Also for flame shader, since blending additive and depthWrite false, order matters but it's fine.
Let me also ensure the flame's cone geometry: ConeGeometry(radius, height, radialSegments=6, heightSegments=3). uv.y goes 0 at bottom to 1 at top. I'll use position-based height anyway.
Potential issue: ConeGeometry with heightSegments=3 creates separate rings; vertices at shared positions duplicated but position-based math is continuous → no cracks. Good.
Now write.
Also: set `scene.background`? Sky dome covers it; set background color 0x0b1322 anyway.
Final code writing time. I'll write it out fully and carefully.
For the moon, also add craters? Simple: use a slightly darker sphere for shadow side? MeshBasicMaterial is flat. Use MeshLambert? To have the moon lit by nothing... Use MeshBasicMaterial with a canvas texture with craters! That's a nice touch: generate a 256x128 canvas with gray base + darker circles for craters, map on sphere. Cheap and good. Let's do it: moon texture canvas with random craters, MeshBasicMaterial({map, fog:false}).
Also the moon glow halo sprite: canvas radial gradient, additive.
Okay. Writing.
Also, the campfire should be visible in the initial view: camera at (8.5,4.5,9.5) looking at (0,1.4,0) — good angle, fire slightly below center.
Let's include some foreground trees framing the shot: place a couple of trees closer (radius 9-12) at edges of the view direction? Trees start at 13. Fine.
Write the code.
Additional: `renderer.shadowMap.autoUpdate` default true.
Let me write the full file.
For the moon directional light shadow camera: left/right/top/bottom ±45, near 1, far 200, position = moonDir*120.
Moon dir: normalize(-0.55, 0.5, -0.7). Position the moon mesh at dir*230.
Directional light position dir*100, target origin.
Shadow bias -0.0006, normalBias 0.05.
Alright.
Also add a subtle rim: a second hemisphere-ish ambient: AmbientLight(0x101828, 0.6)? HemisphereLight already. Use HemisphereLight(0x35486b, 0x0d120c, 0.55) plus AmbientLight(0x0e1622, 0.4).
Final: write the code.
I also want the trees to look "low-poly": cone radial segments 6-7, flat shading — yes.
Let me write out.
Also careful: `THREE.MathUtils.smoothstep(x, min, max)` signature is (x, min, max). Yes.
Writing the final answer now.
For grass, 420 blades * 3 = 1260 verts, one draw call.
Also add a few "bushes" (icosahedron flattened, dark green) scattered.
Let me produce the code.
```js
// ==================== BUILD ====================
```
I'll now write it fully.
Potential issue with shader `#include` inside template literal — three's ShaderChunk resolution happens on the compiled string, so includes in template literals work fine (three parses shader string for #include). Yes.
Let me write.
Also note: for the flames I want them visible from the start; the fire is at origin.
Final code:
```html
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const app = document.getElementById('app');
...
</script>
```
I'll write it now in the answer. Let me carefully compose the whole file.
Also handle the case where WebGL fails — not necessary.
Let me count trees: I'll place 52 trees (>=30 requirement) with radius 12.5–58.
Ground plane radius 160, so trees within.
Also add a "distant treeline" ring of simple cone silhouettes at radius 70-90 (dark, no shadow) to enhance depth: 40 cones. Cheap.
Ok final answer.
One more consideration: performance with 52 trees × 4 meshes = 208 meshes + 40 distant + rocks + grass. Draw calls ~300 per frame + shadow passes (directional 1 pass renders ~300 casters... trees castShadow true → 208). That's ~600 draw calls/frame. Should be OK on a decent GPU but the sandbox may be software-rendered (SwiftShader) — could be slow. Hmm, screenshot after 3 seconds; if rendering is slow, the frame might not complete... Usually fine.
To be safe, limit shadow casters: trees castShadow only if within 30 units of origin (moon shadow ortho ±45 anyway). Distant tree silhouettes: castShadow false. That reduces shadow pass to ~30 trees.
Also reduce tree count to 44. And radial segments 6.
Also use `renderer.setPixelRatio(Math.min(devicePixelRatio, 1.75))`.
Ok. Write.
I also want to make sure the first frame renders promptly: build everything synchronously then animate.
Let me write the final file.