Qwen3.8 Max (0902)
by Qwenreasoning defaultClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 8 min 59 s
- Cost
- $0.151
- Output tokens
- 24,954
- Spent thinking
- 16,233 tokens
- Speed
- 46.5 tok/s
- Console errors
- 0
- First token after
- 1.9 s
- First visible text after
- 6 min 35 s
- Prompt tokens
- 629
- Code size
- 21.8 KB
- Finish reason
- stop
- Served by
- Alibaba
- Model id
qwen/qwen3.8-max-0902- Reasoning sent
model default- Max output
- 131,072 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 · 557 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Clearing — low-poly campfire, three.js</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #04060d; }
canvas { display: block; }
#ui { position: fixed; top: 26px; left: 30px; z-index: 5; pointer-events: none; user-select: none; }
#ui > * { animation: rise 1.2s cubic-bezier(.2,.7,.2,1) both; }
#ui > *:nth-child(2) { animation-delay: .15s; }
#ui > *:nth-child(3) { animation-delay: .3s; }
@keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
.kicker {
font: 600 11px/1 system-ui, -apple-system, sans-serif;
letter-spacing: .34em; text-transform: uppercase;
color: #8b93a8; margin-bottom: 11px; opacity: .9;
}
h1 {
margin: 0;
font-family: Georgia, 'Iowan Old Style', 'Times New Roman', serif;
font-style: italic; font-weight: 600;
font-size: clamp(30px, 4.2vw, 52px);
color: #ffc57e;
text-shadow: 0 0 28px rgba(255,140,50,.42), 0 2px 18px rgba(0,0,0,.65);
}
.sub {
margin: 9px 0 0; max-width: 36ch;
font: 400 13px/1.55 system-ui, -apple-system, sans-serif;
color: #a7aec0;
}
.sub em { color: #d7b98c; font-style: normal; }
.hint {
position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%);
z-index: 5; pointer-events: none; white-space: nowrap;
font: 500 11.5px/1 system-ui, -apple-system, sans-serif;
letter-spacing: .18em; text-transform: uppercase;
color: #d9a468; opacity: .85;
padding: 10px 20px; border: 1px solid rgba(255,160,80,.28);
border-radius: 999px; background: rgba(10,8,6,.38);
transition: opacity 1s ease;
}
.hint.hide { opacity: 0; }
.vignette {
position: fixed; inset: 0; z-index: 3; pointer-events: none;
background: radial-gradient(ellipse at 50% 42%, transparent 52%, rgba(2,3,8,.62) 100%);
}
@media (max-width: 640px) {
#ui { top: 18px; left: 20px; }
.sub { display: none; }
.hint { font-size: 10px; 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="ui">
<div class="kicker">Low-poly night study · three.js r170</div>
<h1>The Clearing</h1>
<p class="sub">A campfire among the pines — <em>sparks drift up, the moon keeps watch, and the fog rolls in between the trees.</em></p>
</div>
<div class="hint" id="hint">drag to orbit · scroll to zoom</div>
<div class="vignette"></div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
/* ---------------- helpers ---------------- */
const TAU = Math.PI * 2;
const rand = (a = 1, b) => b === undefined ? Math.random() * a : a + Math.random() * (b - a);
const pick = arr => arr[(Math.random() * arr.length) | 0];
const fract = x => x - Math.floor(x);
const rand2 = (x, z) => fract(Math.sin(x * 127.1 + z * 311.7) * 43758.5453);
const smoothstep = (a, b, x) => { const t = THREE.MathUtils.clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
function groundY(x, z) {
const d = Math.hypot(x, z);
const amp = smoothstep(5.5, 16, d); // flat around the fire
let h = Math.sin(x * 0.16) * Math.cos(z * 0.19) * 0.55
+ Math.sin(x * 0.42 + z * 0.31) * 0.22
+ Math.cos(x * 0.07 - z * 0.11) * 0.30
+ (rand2(x, z) - 0.5) * 0.45;
return h * amp;
}
function makeSoftTexture() {
const c = document.createElement('canvas'); c.width = c.height = 64;
const ctx = c.getContext('2d');
const g = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
g.addColorStop(0, 'rgba(255,255,255,1)');
g.addColorStop(0.35, 'rgba(255,255,255,0.6)');
g.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = g; ctx.fillRect(0, 0, 64, 64);
return new THREE.CanvasTexture(c);
}
const softTex = makeSoftTexture();
/* ---------------- renderer / scene / camera ---------------- */
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;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x04060d);
scene.fog = new THREE.Fog(0x0a1220, 16, 62);
const camera = new THREE.PerspectiveCamera(55, innerWidth / innerHeight, 0.1, 800);
camera.position.set(8.5, 4.2, 10.5);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1.1, 0);
controls.enableDamping = true;
controls.dampingFactor = 0.06;
controls.minDistance = 4.5;
controls.maxDistance = 40;
controls.minPolarAngle = 0.15;
controls.maxPolarAngle = Math.PI / 2 - 0.06; // keep camera above the ground
controls.screenSpacePanning = false;
controls.autoRotate = true;
controls.autoRotateSpeed = -0.5;
controls.update();
const hint = document.getElementById('hint');
controls.addEventListener('start', () => { controls.autoRotate = false; hint.classList.add('hide'); });
/* ---------------- sky dome, stars, moon ---------------- */
const sky = new THREE.Mesh(
new THREE.SphereGeometry(300, 24, 12),
new THREE.ShaderMaterial({
side: THREE.BackSide, depthWrite: false, fog: false,
uniforms: {
top: { value: new THREE.Color(0x01030a) },
mid: { value: new THREE.Color(0x0a1430) },
bot: { value: new THREE.Color(0x05070d) }
},
vertexShader: `varying vec3 vP; void main(){ vP = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
fragmentShader: `
varying vec3 vP; uniform vec3 top, mid, bot;
void main(){
float h = normalize(vP).y;
vec3 c = h > 0.0 ? mix(mid, top, pow(h, 0.55)) : mix(mid, bot, pow(-h, 0.5));
gl_FragColor = vec4(c, 1.0);
}`
})
);
sky.renderOrder = -2;
scene.add(sky);
function starLayer(count, size, tintA, tintB) {
const pos = new Float32Array(count * 3), col = new Float32Array(count * 3);
const v = new THREE.Vector3(), cA = new THREE.Color(tintA), cB = new THREE.Color(tintB), c = new THREE.Color();
for (let i = 0; i < count; i++) {
v.set(rand(-1, 1), Math.abs(rand(-0.1, 1)), rand(-1, 1)).normalize().multiplyScalar(rand(255, 285));
pos.set([v.x, v.y, v.z], i * 3);
c.lerpColors(cA, cB, Math.random()); col.set([c.r, c.g, c.b], i * 3);
}
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: 0.9, fog: false, depthWrite: false
});
const p = new THREE.Points(g, m); p.renderOrder = -1;
scene.add(p);
return m;
}
const starMatA = starLayer(650, 2.0, 0xffffff, 0xaebfff);
const starMatB = starLayer(450, 3.1, 0xfff6dd, 0xd7e3ff);
const moonPos = new THREE.Vector3(-85, 62, -120);
const moon = new THREE.Mesh(new THREE.SphereGeometry(7, 18, 14), new THREE.MeshBasicMaterial({ color: 0xe6ecfb, fog: false }));
moon.position.copy(moonPos);
scene.add(moon);
const moonGlow = new THREE.Sprite(new THREE.SpriteMaterial({ map: softTex, color: 0x8ea6e8, transparent: true, opacity: 0.42, blending: THREE.AdditiveBlending, depthWrite: false, fog: false }));
moonGlow.position.copy(moonPos);
moonGlow.scale.setScalar(52);
scene.add(moonGlow);
/* ---------------- lights ---------------- */
scene.add(new THREE.HemisphereLight(0x28355a, 0x0a0e08, 0.55));
const moonLight = new THREE.DirectionalLight(0x8fa5dd, 0.35);
moonLight.position.copy(moonPos);
scene.add(moonLight);
const fireLight = new THREE.PointLight(0xff8433, 60, 0, 2);
fireLight.position.set(0, 1.05, 0);
fireLight.castShadow = true;
fireLight.shadow.mapSize.set(1024, 1024);
fireLight.shadow.bias = -0.004;
fireLight.shadow.camera.near = 0.3;
fireLight.shadow.camera.far = 45;
scene.add(fireLight);
/* ---------------- ground ---------------- */
{
const geo = new THREE.PlaneGeometry(170, 170, 90, 90);
geo.rotateX(-Math.PI / 2);
const pos = geo.attributes.position;
const colors = new Float32Array(pos.count * 3);
const cA = new THREE.Color(0x182f1f), cB = new THREE.Color(0x0d1a12);
const scorch = new THREE.Color(0x1c130c), dirt = new THREE.Color(0x2a2016);
const c = new THREE.Color();
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i), z = pos.getZ(i);
pos.setY(i, groundY(x, z));
const d = Math.hypot(x, z);
c.copy(cB).lerp(cA, rand2(x + 9.7, z - 4.3) * 0.9);
c.multiplyScalar(1 - smoothstep(20, 70, d) * 0.55);
if (d < 2.1) c.lerp(scorch, 1 - d / 2.1);
else if (d < 3.6) c.lerp(dirt, 0.35 * (1 - (d - 2.1) / 1.5));
colors.set([c.r, c.g, c.b], i * 3);
}
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geo.computeVertexNormals();
const ground = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({
vertexColors: true, flatShading: true, roughness: 1, metalness: 0
}));
ground.receiveShadow = true;
scene.add(ground);
}
/* ---------------- grass tufts ---------------- */
{
const count = 200;
const geo = new THREE.ConeGeometry(0.055, 0.36, 4);
geo.translate(0, 0.18, 0);
const mesh = new THREE.InstancedMesh(geo, new THREE.MeshStandardMaterial({ color: 0xffffff, flatShading: true, roughness: 1 }), count);
const dummy = new THREE.Object3D(), gc = new THREE.Color();
for (let i = 0; i < count; i++) {
const r = 2.9 + Math.pow(Math.random(), 0.75) * 21, a = rand(TAU);
const x = Math.cos(a) * r, z = Math.sin(a) * r;
dummy.position.set(x, groundY(x, z) - 0.02, z);
dummy.rotation.set((Math.random() - 0.5) * 0.35, rand(TAU), (Math.random() - 0.5) * 0.35);
const s = rand(0.7, 1.6);
dummy.scale.set(s, s * rand(0.9, 1.7), s);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
gc.setHSL(0.36 + Math.random() * 0.05, 0.45, 0.10 + Math.random() * 0.08);
mesh.setColorAt(i, gc);
}
mesh.instanceColor.needsUpdate = true;
scene.add(mesh);
}
/* ---------------- rocks / logs / seats around the fire ---------------- */
const rockMat = new THREE.MeshStandardMaterial({ color: 0x565d68, flatShading: true, roughness: 1 });
const rockMat2 = new THREE.MeshStandardMaterial({ color: 0x3e4550, flatShading: true, roughness: 1 });
const barkMat = new THREE.MeshStandardMaterial({ color: 0x3b2a1a, flatShading: true, roughness: 1 });
const charMat = new THREE.MeshStandardMaterial({ color: 0x241a12, flatShading: true, roughness: 1 });
function rock(x, z, s, mat = pick([rockMat, rockMat2])) {
const m = new THREE.Mesh(new THREE.DodecahedronGeometry(s, 0), mat);
m.position.set(x, groundY(x, z) + s * 0.28, z);
m.rotation.set(rand(TAU), rand(TAU), rand(TAU));
m.scale.y = rand(0.6, 0.85);
m.castShadow = m.receiveShadow = true;
scene.add(m);
}
// stone ring
for (let i = 0; i < 9; i++) {
const a = (i / 9) * TAU + rand(-0.15, 0.15);
rock(Math.cos(a) * 1.72, Math.sin(a) * 1.72, rand(0.26, 0.44));
}
// scattered rocks
for (let i = 0; i < 7; i++) {
const a = rand(TAU), r = rand(5, 20);
rock(Math.cos(a) * r, Math.sin(a) * r, rand(0.3, 0.75));
}
// one mossy boulder + stumps near the clearing edge
rock(4.6, -3.8, 0.95, new THREE.MeshStandardMaterial({ color: 0x3d4a3a, flatShading: true, roughness: 1 }));
for (let i = 0; i < 3; i++) {
const a = rand(TAU), r = rand(6, 14);
const x = Math.cos(a) * r, z = Math.sin(a) * r;
const st = new THREE.Mesh(new THREE.CylinderGeometry(rand(0.22, 0.3), rand(0.3, 0.38), rand(0.4, 0.65), 6), barkMat);
st.position.set(x, groundY(x, z) + 0.2, z);
st.rotation.y = rand(TAU); st.rotation.z = rand(-0.1, 0.1);
st.castShadow = st.receiveShadow = true;
scene.add(st);
}
// seat logs
for (let i = 0; i < 2; i++) {
const a = rand(TAU), r = 3.3 + i * 0.6;
const x = Math.cos(a) * r, z = Math.sin(a) * r;
const log = new THREE.Mesh(new THREE.CylinderGeometry(0.24, 0.27, 2.3, 6), barkMat);
log.position.set(x, groundY(x, z) + 0.22, z);
log.rotation.z = Math.PI / 2; log.rotation.y = rand(TAU);
log.castShadow = log.receiveShadow = true;
scene.add(log);
}
// charred patch
{
const patch = new THREE.Mesh(new THREE.CircleGeometry(1.65, 12), new THREE.MeshStandardMaterial({ color: 0x120c08, roughness: 1 }));
patch.rotation.x = -Math.PI / 2;
patch.position.y = 0.03;
patch.receiveShadow = true;
scene.add(patch);
}
/* ---------------- the campfire ---------------- */
// teepee logs
const up = new THREE.Vector3(0, 1, 0);
for (let i = 0; i < 5; i++) {
const a = (i / 5) * TAU + rand(-0.3, 0.3);
const base = new THREE.Vector3(Math.cos(a) * 0.85, 0.06, Math.sin(a) * 0.85);
const apex = new THREE.Vector3(rand(-0.08, 0.08), rand(0.9, 1.1), rand(-0.08, 0.08));
const dir = apex.clone().sub(base);
const len = dir.length();
const log = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.13, len, 5), i % 2 ? charMat : barkMat);
log.position.copy(base).addScaledVector(dir, 0.5);
log.quaternion.setFromUnitVectors(up, dir.normalize());
log.castShadow = true;
scene.add(log);
}
// glowing embers
const emberMat = new THREE.MeshStandardMaterial({ color: 0x1a0d06, emissive: 0xff5a1f, emissiveIntensity: 2.2, flatShading: true, roughness: 1 });
const embers = new THREE.Mesh(new THREE.IcosahedronGeometry(0.34, 0), emberMat);
embers.position.set(0, 0.24, 0);
embers.scale.y = 0.6;
scene.add(embers);
// flames — stacked additive low-poly cones
const flameGroup = new THREE.Group();
flameGroup.position.y = 0.3;
scene.add(flameGroup);
const flames = [];
function addFlame(r, h, color, opacity, spread) {
const g = new THREE.ConeGeometry(r, h, 6);
g.translate(0, h / 2, 0);
const m = new THREE.Mesh(g, new THREE.MeshBasicMaterial({
color, transparent: true, opacity, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide
}));
const a = rand(TAU), rr = rand(spread);
m.position.set(Math.cos(a) * rr, 0, Math.sin(a) * rr);
m.userData = { ox: m.position.x, oz: m.position.z, ph: rand(TAU), sp: rand(5, 9), rs: rand(-1.6, 1.6) };
flameGroup.add(m);
flames.push(m);
}
for (let i = 0; i < 6; i++) addFlame(rand(0.26, 0.4), rand(0.9, 1.35), pick([0xff4d12, 0xff6a1a, 0xff8426]), 0.62, 0.1);
for (let i = 0; i < 4; i++) addFlame(rand(0.15, 0.22), rand(0.55, 0.8), pick([0xffb13a, 0xffc94d]), 0.85, 0.05);
addFlame(0.1, 0.48, 0xfff0b0, 0.95, 0.02);
addFlame(0.06, 0.3, 0xfff8dc, 1.0, 0.02);
// warm glow sprite (fake bloom)
const glow = new THREE.Sprite(new THREE.SpriteMaterial({ map: softTex, color: 0xff7a2e, transparent: true, opacity: 0.3, blending: THREE.AdditiveBlending, depthWrite: false }));
glow.position.set(0, 1.0, 0);
glow.scale.setScalar(5.5);
scene.add(glow);
/* ---------------- sparks ---------------- */
const SPARKS = 150;
const sPos = new Float32Array(SPARKS * 3), sCol = new Float32Array(SPARKS * 3);
const sVel = new Float32Array(SPARKS * 3), sLife = new Float32Array(SPARKS), sMax = new Float32Array(SPARKS);
function resetSpark(i) {
const a = rand(TAU), r = rand(0.28);
sPos[i * 3] = Math.cos(a) * r;
sPos[i * 3 + 1] = rand(0.4, 0.9);
sPos[i * 3 + 2] = Math.sin(a) * r;
sVel[i * 3] = rand(-0.3, 0.3);
sVel[i * 3 + 1] = rand(1.2, 2.6);
sVel[i * 3 + 2] = rand(-0.3, 0.3);
sMax[i] = sLife[i] = rand(1.1, 2.6);
}
for (let i = 0; i < SPARKS; i++) { resetSpark(i); sLife[i] = rand(0, sMax[i]); }
const sparkGeo = new THREE.BufferGeometry();
sparkGeo.setAttribute('position', new THREE.BufferAttribute(sPos, 3));
sparkGeo.setAttribute('color', new THREE.BufferAttribute(sCol, 3));
const sparkPoints = new THREE.Points(sparkGeo, new THREE.PointsMaterial({
size: 0.11, map: softTex, vertexColors: true, transparent: true,
blending: THREE.AdditiveBlending, depthWrite: false, sizeAttenuation: true
}));
sparkPoints.frustumCulled = false;
scene.add(sparkPoints);
/* ---------------- smoke ---------------- */
const smoke = [];
for (let i = 0; i < 7; i++) {
const m = new THREE.SpriteMaterial({ map: softTex, color: 0x9aa3b5, transparent: true, opacity: 0, depthWrite: false });
const s = new THREE.Sprite(m);
scene.add(s);
smoke.push({ sprite: s, off: Math.random(), speed: rand(0.1, 0.15), wind: rand(-1.2, 1.2), dir: Math.random() < 0.5 ? -1 : 1 });
}
/* ---------------- fireflies ---------------- */
const FLIES = 26;
const fPos = new Float32Array(FLIES * 3), fCol = new Float32Array(FLIES * 3);
const flies = [];
for (let i = 0; i < FLIES; i++) {
const a = rand(TAU), r = rand(4.5, 15);
flies.push({
cx: Math.cos(a) * r, cz: Math.sin(a) * r,
cy: rand(0.8, 2.6), rr: rand(0.5, 1.6), sp: rand(0.25, 0.6),
ph: rand(TAU), blink: rand(1.2, 3.2)
});
}
const flyGeo = new THREE.BufferGeometry();
flyGeo.setAttribute('position', new THREE.BufferAttribute(fPos, 3));
flyGeo.setAttribute('color', new THREE.BufferAttribute(fCol, 3));
const flyPoints = new THREE.Points(flyGeo, new THREE.PointsMaterial({
size: 0.22, map: softTex, vertexColors: true, transparent: true,
blending: THREE.AdditiveBlending, depthWrite: false, sizeAttenuation: true
}));
flyPoints.frustumCulled = false;
scene.add(flyPoints);
/* ---------------- forest ---------------- */
const trunkGeo = new THREE.CylinderGeometry(0.16, 0.26, 1, 5); trunkGeo.translate(0, 0.5, 0);
const coneGeo = new THREE.ConeGeometry(1, 1, 6); coneGeo.translate(0, 0.5, 0);
const trunkMat = new THREE.MeshStandardMaterial({ color: 0x2f2013, flatShading: true, roughness: 1 });
const pineMats = [0x0e2a1a, 0x123322, 0x0b2317, 0x16392a, 0x0d2c20]
.map(c => new THREE.MeshStandardMaterial({ color: c, flatShading: true, roughness: 1 }));
const LEVELS = [
{ r: 1.25, h: 1.7, y: 0.9 },
{ r: 0.95, h: 1.45, y: 1.75 },
{ r: 0.62, h: 1.2, y: 2.5 }
];
const sway = [];
const TREES = 52;
for (let i = 0; i < TREES; i++) {
const r = 9.5 + Math.pow(Math.random(), 0.85) * 38; // never on the fire
const a = rand(TAU);
const x = Math.cos(a) * r, z = Math.sin(a) * r;
const g = new THREE.Group();
const s = rand(0.85, 2.0);
const mat = pick(pineMats);
const trunk = new THREE.Mesh(trunkGeo, trunkMat);
trunk.scale.set(s, 1.1 * s, s);
trunk.castShadow = r < 32;
g.add(trunk);
for (const L of LEVELS) {
const cone = new THREE.Mesh(coneGeo, mat);
const rr = L.r * s * rand(0.88, 1.12), hh = L.h * s * rand(0.9, 1.1);
cone.scale.set(rr, hh, rr);
cone.position.y = L.y * s;
cone.rotation.y = rand(TAU);
cone.castShadow = r < 32;
g.add(cone);
}
const tilt = rand(-0.05, 0.05);
g.rotation.y = rand(TAU);
g.rotation.z = tilt;
g.position.set(x, groundY(x, z) - 0.08, z);
scene.add(g);
sway.push({ g, ph: rand(TAU), base: tilt });
}
/* ---------------- animation ---------------- */
const clock = new THREE.Clock();
function animate() {
const dt = Math.min(clock.getDelta(), 0.05);
const t = clock.elapsedTime;
// fire flicker (smooth noise)
const fl = 0.5 + 0.5 * (Math.sin(t * 10.7) * 0.5 + Math.sin(t * 17.3 + 1.7) * 0.3 + Math.sin(t * 29.1 + 4.2) * 0.2);
fireLight.intensity = 42 + fl * 46;
fireLight.position.set(Math.sin(t * 7.3) * 0.08, 1.0 + fl * 0.18, Math.cos(t * 6.1) * 0.08);
glow.material.opacity = 0.2 + fl * 0.22;
glow.scale.setScalar(5 + fl * 1.4);
emberMat.emissiveIntensity = 1.5 + fl * 1.8;
flameGroup.scale.y = 0.9 + fl * 0.22;
// flames
for (const f of flames) {
const u = f.userData;
let sy = 0.8 + 0.28 * Math.sin(t * u.sp + u.ph) + 0.12 * Math.sin(t * u.sp * 2.7 + u.ph * 1.7);
f.scale.y = Math.max(0.35, sy);
const sx = 1 + 0.18 * Math.sin(t * u.sp * 1.3 + u.ph * 2);
f.scale.x = f.scale.z = sx;
f.position.x = u.ox + 0.04 * Math.sin(t * 2.3 + u.ph);
f.position.z = u.oz + 0.04 * Math.cos(t * 2.1 + u.ph);
f.rotation.y = t * u.rs + u.ph;
}
// sparks
for (let i = 0; i < SPARKS; i++) {
sLife[i] -= dt;
if (sLife[i] <= 0) resetSpark(i);
const i3 = i * 3;
sVel[i3 + 1] += 0.35 * dt; // buoyancy
sVel[i3] += (Math.sin(t * 3 + i) * 0.35 - sPos[i3] * 0.4) * dt;
sPos[i3] += sVel[i3] * dt;
sPos[i3 + 1] += sVel[i3 + 1] * dt;
sPos[i3 + 2] += sVel[i3 + 2] * dt;
const f = sLife[i] / sMax[i], b = f * f;
sCol[i3] = b;
sCol[i3 + 1] = (0.35 + 0.55 * f) * b;
sCol[i3 + 2] = (0.08 + 0.3 * f * f) * b;
}
sparkGeo.attributes.position.needsUpdate = true;
sparkGeo.attributes.color.needsUpdate = true;
// smoke
for (const s of smoke) {
const p = fract(t * s.speed + s.off);
const y = 1.2 + p * 5.2;
s.sprite.position.set(
Math.sin(p * 4 + s.off * 9) * 0.4 + p * s.wind,
y,
Math.cos(p * 3.3 + s.off * 7) * 0.4
);
const sc = 0.7 + p * 3.4;
s.sprite.scale.set(sc, sc, 1);
s.sprite.material.opacity = Math.sin(p * Math.PI) * 0.12;
s.sprite.material.rotation += dt * 0.15 * s.dir;
}
// fireflies
for (let i = 0; i < FLIES; i++) {
const f = flies[i], i3 = i * 3;
fPos[i3] = f.cx + Math.cos(t * f.sp + f.ph) * f.rr;
fPos[i3 + 1] = f.cy + Math.sin(t * 0.6 + f.ph * 2) * 0.5;
fPos[i3 + 2] = f.cz + Math.sin(t * f.sp * 0.9 + f.ph) * f.rr;
const b = Math.pow(Math.max(0, Math.sin(t * f.blink + f.ph)), 3);
fCol[i3] = 0.72 * b; fCol[i3 + 1] = b; fCol[i3 + 2] = 0.45 * b;
}
flyGeo.attributes.position.needsUpdate = true;
flyGeo.attributes.color.needsUpdate = true;
// gentle tree sway + star twinkle
for (const s of sway) s.g.rotation.z = s.base + Math.sin(t * 0.55 + s.ph) * 0.015;
starMatA.opacity = 0.65 + 0.3 * Math.sin(t * 1.3);
starMatB.opacity = 0.65 + 0.3 * Math.sin(t * 1.7 + 2.1);
controls.update();
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
/* ---------------- resize ---------------- */
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
});
</script>
</body>
</html>Reasoning trace (16,233 tokens)
Let's build a low-poly 3D campfire scene in Three.js. Requirements:
- Ground with low-poly rocks and logs around the central campfire
- 30+ low-poly trees randomly placed around the clearing (not on top of the fire)
- Campfire with animated flame and a flickering warm point light that casts shadows
- Rising sparks as a simple particle system
- Dark night sky with stars and moon, fog that fades distant trees
- OrbitControls with limits to keep the camera above ground
- Renderer fills the entire window and supports resize
- Import map with three@0.170.0
I want it to feel alive and rich, with layered depth — a glowing sky gradient, drifting embers, fireflies, etc. The scene itself should be rich: stars, moon with glow, fog, flickering fire, sparks, fireflies, gently swaying trees, and maybe some subtle camera auto-drift before interaction. Since this is a 3D scene, adding ambient motion is fine.
Design considerations:
- Low-poly aesthetic: use flatShading on MeshStandardMaterial, icosahedrons/cones for trees, dodecahedrons for rocks.
- Ground: a displaced circular plane with slight vertex noise, dark green/gray tones.
- Fire: logs (cylinders arranged teepee-style), stones in a ring, flames made of stacked cones animated with vertex wiggle or scale/rotation — a good approach is to make multiple cone meshes with flat shading and animate their scale/position/rotation each frame, with emissive orange/yellow color. Alternatively, custom shader flames. Simpler and more robust: several cone flames with flicker scale and slight sway, plus an emissive inner core.
- Point light: warm color (0xff7733), flickering intensity and slight positional jitter, castShadow true. Shadow settings: shadow map size 1024, near/far.
- Sparks: THREE.Points with BufferGeometry, positions updated each frame, additive blending, orange color, life cycle resets. Per-point size would use a custom attribute via ShaderMaterial... Simpler: PointsMaterial with sizeAttenuation and additive blending. Varying size per point is not directly possible in PointsMaterial, so use a single size. That's fine. Could also use two particle systems (sparks + embers).
- Stars: Points scattered on a large sphere. Twinkle with slight opacity variation? Simple: static Points, two layers, can do that. Could also animate the material's opacity with sin for twinkle.
- Moon: emissive material, circle/sphere mesh plus a glow sprite (canvas-generated radial gradient texture). Moonlight: cool directional light from the moon's direction, low intensity, can also cast shadows but shadows for the point light are required. Add subtle blueish fill.
- Fog: THREE.FogExp2 or Fog with a dark blue color matching the sky.
- Sky: scene.background as a dark color, or a big sphere with a gradient shader. A large gradient dome would be nice: a BackSide ShaderMaterial sphere with vertical gradient from deep navy to near-black. Stars are Points beyond that. Fog should not affect the sky dome... Fog affects materials unless material.fog=false. Set sky dome material fog=false, same for stars and moon.
- Trees: cones stacked (2–3 tiers) for pines, with cylindrical trunks. Randomize height, hue variation, position on a ring from clearing radius ~8 to ~40, avoiding the center. Slight random rotation/tilt. Add subtle sway? Could also do shader-based sway, but simple: store tree group and slightly rotate with sin(time + phase). Cheap and lively for 30–60 trees. Trees cast/receive shadows. Many shadow casters are fine with a point light's 6-face shadow map. Tree count ~40 is fine.
- Rocks: dodecahedron geometry with random scale/rotation, gray flat shading, ring near fire + scattered.
- Logs: teepee arrangement around the center plus 1–2 sitting logs. Low-segment cylinders with flat shading.
- Fireflies: a few drifting greenish-yellow points with sin paths — adds ambient life.
- Fireflies: greenish-yellow points drifting along sin paths — adds ambient life.
- OrbitControls: enableDamping, minDistance ~4, maxDistance ~45, maxPolarAngle ~Math.PI/2 - 0.05 to stay above ground, target slightly above the fire (0, 1, 0). Auto-rotate until user interacts? Requirement is OrbitControls to look around. autoRotate adds motion for the first draw. A screenshot is taken at about 3 seconds with no interaction — so a slowly auto-rotating camera looks good. Set controls.autoRotate = true, autoRotateSpeed slow, and stop on 'start' event (user interaction).
- Also, display an unobtrusive UI overlay: title like "Ember Hollow" and hint "drag to look around • scroll to zoom". Non-blocking, corner-positioned, styled with a system font. Pair a display face with a legible body — but no external fonts allowed (no network requests except the allowed CDN). So use a system font stack: display could be something like Georgia/serif? For a nighttime campfire, a warm serif display (Georgia, 'Times New Roman') with letter spacing and a body system-ui would pair nicely. Title in warm amber.
- Handle resize: update camera aspect and renderer size on window resize.
- Tone mapping: ACESFilmic, exposure ~1.1 for a nice glow. Renderer outputColorSpace defaults to SRGB in 0.170. Use shadows PCFSoft.
Performance: 40+ trees each 3 cones + trunk = ~160 meshes, fine. Can also merge but not needed. Points for shadows: PointLight shadow uses cube map, renders scene 6 times — 200 draw calls × 6 = 1200 draw calls... that might be heavy but fine on modern machines. Could reduce tree shadow casting: trees castShadow true, receive false probably. Or use one PointLight shadow with mapSize 1024. Should be OK. Alternative: make directional moon light not cast shadows. Only the point light casts shadows as required.
Let's cut down: shadow-casting point light only covers nearby area? PointLight distance limits the light but shadows still render all casters within the camera's shadow range. I can set light.shadow.camera.far = 60. Fine.
Flame animation: create a group of ~7 cones with varying sizes, each with own phase. Each frame: yScale = base + sin flicker with noise, x sway offset, rotation.y increment. Emissive material (MeshBasicMaterial with vertexColors? Or). Use MeshBasicMaterial (no lighting) with orange/yellow colors and additive blending to look glowing. Low-poly cone with 6 radial segments, flat shading — set flatShading on MeshBasicMaterial? Basic ignores shading but low-poly silhouette is what matters.
Better flame: outer flame cones (orange, transparent 0.9, additive) + inner (yellow) + core (whitish). Animate scale and position. Also add subtle emissive "coal" disc under logs.
Heat glow sprite: canvas radial gradient texture as an additive blending sprite over the fire — adds glow. Also good for moon glow.
Smoke: could add rising grayish puffs — a few sprite planes drifting up while slowly rotating, with opacity fading. Adds atmosphere. Use 6–8 sprites of soft radial texture, low-opacity gray, additive? Normal blending with low opacity gray. Simpler: Points with larger size and custom texture. Can make another particle system with soft sprite texture for smoke. Keep it understated so it doesn't obscure the fire. Use sprites of canvas-generated soft circle.
Spark system: about 120 particles, each with velocity, life. Positions updated in loop. Colors fade with life — PointsMaterial has single color. Can fade using vertex colors and material vertexColors true, updating color attribute each frame. Additive blending, depthWrite false.
Stars: 1200 points on sphere of radius ~300 (beyond fog? Fog would hide them — set material.fog=false on stars). Two sizes.
Moon: sphere of radius 8 at say (-60, 70, -90), MeshBasicMaterial pale color, fog=false. Plus glow sprite. DirectionalLight from moon direction, color 0x8fa8ff... moonlight is more like 0x9db4ff, intensity 0.25.
Sky gradient dome: sphere of radius 400, ShaderMaterial with top color #050a18 and bottom near horizon #0d1626? Horizon slightly warmer? Keep it night-blue. Add a very subtle warm tint near horizon behind trees? Keep simple gradient. Fog color to match horizon color (#0a1020). Use Fog(color, near 18, far 70) so distant trees fade. Sky dome unaffected (fog:false on ShaderMaterial defaults? ShaderMaterial ignores fog unless defined — right, ShaderMaterial default fog:false). Stars PointsMaterial fog false.
Ground: CircleGeometry radius 60 with segments, or PlaneGeometry 140x140 with segments 60x60 rotated -PI/2, vertices displaced with noise (combination of sin + random), flatten area near center (radius < 6 stays near 0). Compute vertex colors for subtle variation? Use dark color like #1a2a1f with flatShading true and vertexColors for mottling. Low-poly ground with flat shading + vertex color variation looks nice under firelight.
Let's write the noise: simple pseudo-random by hash, or Math.random per vertex with smoothing by position-based sin. Do: y = (Math.sin(x*0.3)*Math.cos(z*0.25)*0.4 + random*0.25) * edgeFactor, where edgeFactor ramps from 0 in center to 1 past r>7. Also color: mix of dark greens #16241b and #243b2a with random, plus slightly warmer near center? Fire light will tint it anyway.
Tree placement: for i in count: pick radius r between 9 and 48, angle random. But ensure a clearing feel: no trees for r<9. Also avoid perfect ring artifacts: random. Use rejection sampling to keep minimum distance between trees? Simpler: allow random. Look fine. Position y = groundHeight(x,z) roughly — need same height function for objects. Implement a function groundY(x,z) that returns the same displacement used on plane, so rocks/logs/trees sit correctly. Since plane displacement is computed from same function, fine.
Ground geometry displacement: PlaneGeometry rotated. Easier: create PlaneGeometry(w,h,seg,seg), iterate positions attribute, set z (height before rotate) using x,y (which are plane coords). After rotateX(-PI/2), plane's local y becomes world -z... To keep simple, compute heights on world coords: after rotateX(-π/2), position attribute: x stays, old y -> z? Let's just create geometry, rotateX first, then iterate: pos.x = x, pos.z = z, set pos.y = h(x,z).
groundY(x,z): base = sin/cos gentle undulation + hash noise. Amplitude grows with distance from center: amp = smoothstep(6, 14, r). h = (sin(x*0.18)*cos(z*0.22)*0.5 + sin(x*0.45+z*0.3)*0.18 + (hash-0.5)*0.35) * amp. Keep amplitude modest (max ~0.7) so trees don't float weirdly. Trees planted at groundY minus small sink offset.
hash function: function rand2(x,z){ const s = Math.sin(x*127.1 + z*311.7)*43758.5453; return s - Math.floor(s); } deterministic.
Trees: pine = group. Trunk: CylinderGeometry(0.18,0.28,1.2,5) brown. Foliage: 3 cones ConeGeometry(radius, height, 6) stacked, decreasing radius, color varying greens (dark for night: #0f2d1d, #143a24, #0c2418 variations). Slightly tint some bluish. Total ~50 trees. Each tree castShadow=true on foliage and trunk, receiveShadow false to save perf. Random scale 0.7–1.6, random rotation.y, small tilt.
Sway: store in array sway = {group, phase, amp}. Each frame group.rotation.z = baseTilt + sin(t*0.6+phase)*0.01 — subtle.
Rocks: 8–10 around fire ring at r ~1.6–2.0, plus 6–8 scattered in clearing. DodecahedronGeometry(radius 0.25-0.6, 0), scale y flatten 0.7, gray #4a4f57 with slight variation, flatShading.
Fire pit logs: 4–5 cylinders (r 0.12, length 1.6) arranged teepee: rotate to lean into center, tip at ~0.9 high. Plus 2 horizontal burnt logs (dark, emissive ends?). Keep simple: dark brown #2a1c12. Add glowing embers: small icosahedron in center, emissive orange MeshStandardMaterial, pulsing emissiveIntensity.
Point light: position (0,1.2,0), color 0xff8a3d... warm: 0xff7a33, intensity flickering base ~60 (r170 uses physical units? Since r155 lights use physical units by default? In three 0.170, default of WebGLRenderer.useLegacyLights is false, so PointLight intensity is candela. Values like 60–120 with decay 2 work well. Set decay 2, distance 0 (infinite). Flicker: intensity = 55 + noise. Small positional jitter too. castShadow, shadow.mapSize 1024, bias -0.005, near 0.1 far 60? Actually shadow camera far for point light defaults to 500. Set to 60.
Also a second tiny light? ember pulse light maybe not needed.
Ambient: very low ambient (0x223344, 0.35?) plus hemisphere light: sky #16233f ground #0a0f0a intensity 0.35. Moon directional 0x88aaff 0.3 no shadow.
Flame mesh: build Flame class: 5 outer cones (radius ~0.32, height 1.1, 6 segments), 3 inner (0.18, 0.7), colors. Material: MeshBasicMaterial({color, transparent:true, opacity 0.85, blending: AdditiveBlending, depthWrite:false, side:DoubleSide? front is fine}). Animate: for each cone, y = base + sin(t*speed+phase)*amp plus noise. Scale x/z pulse. Small x/z positional sway. Rotate y slowly. Also overall flame group scale flicker.
Additive blending with tone mapping will blow out nicely. Set material toneMapped default true is fine.
Also cone geometry: translate geometry so origin is at base: geometry.translate(0, height/2, 0) so scaling y stretches upward from base.
Sparks: 140 particles. Attributes: position, plus arrays vel, life, maxLife stored in JS. Each frame: life -= dt. If life<=0 reset at fire base with random position radius 0.3, upward velocity 1.2–2.6 with slight lateral drift. Update positions, apply slight sin for wind drift. Color: from bright yellow-white to deep red based on life fraction. Update color attribute. PointsMaterial({size:0.09, vertexColors:true, blending:Additive, depthWrite:false, transparent:true, sizeAttenuation:true, map: small soft circle texture for round sparks?}). Map with alpha from canvas radial gradient makes nicer round sparks. Use same soft texture.
Smoke: 7 sprites, each a SpriteMaterial({map:softTexture, color:0x555a66? maybe 0x8a8f9c, transparent, opacity varying, depthWrite:false, blending: NormalBlending}). Cycle: each smoke puff starts small at y≈1.4 above flame, rises to y≈6, expands, fades in then out. Drifts with wind. Rotation via material.rotation. Keep understated (max opacity ~0.16).
Fireflies: 24 points, or small individual sprites with additive green-yellow (#b6ff6e → maybe 0xd4ff7a). Move with sin paths around clearing radius 4–14, height 0.5–2.5. Blinking by color intensity? PointsMaterial single color with vertexColors can vary alpha? Points don't do alpha per vertex easily. Use vertexColors and modulate brightness — with additive blending darker color = dimmer. Nice: firefly flicker by scaling color value.
Glow sprite over fire: big soft radial sprite, additive, orange, scale ~6, subtle pulse in opacity — gives a bloom-ish effect without post-processing.
Moon glow sprite similarly, bluish-white, scale 40.
Camera: PerspectiveCamera fov 55, position (9, 4.5, 11), look at target (0,1.3,0). Controls damping 0.06, minDistance 4.5, maxDistance 40, maxPolarAngle Math.PI/2 - 0.06, minPolarAngle maybe 0.15. Target y=1.2. AutoRotate 0.6 speed, disabled on pointerdown/interaction: controls.addEventListener('start', ()=> controls.autoRotate=false).
UI overlay: top-left title block: small label "THREE.JS NIGHT SCENE" wide letter-spaced, big title "The Clearing" warm amber serif italic? Subtle hint text at bottom-center: "drag to orbit · scroll to zoom". Style: warm amber #ffb46b, text-shadow glow, background transparent with subtle vignette from CSS radial-gradient overlay (pointer-events none). Add a vignette: fixed div with radial gradient transparent → rgba(0,0,0,0.55) at edges. Also a subtle film grain? Overkill maybe. Skip or use light noise via repeating gradient — skip for perf.
Font: display: Georgia, 'Times New Roman', serif. Body/hint: system-ui.
Handle prefers-reduced-motion? Can reduce auto-rotate. Skip. Keep code clean.
Resize: on resize update camera.aspect, renderer.setSize(innerWidth, innerHeight), setPixelRatio(min(devicePixelRatio,2)).
Clock delta cap 0.05.
Color management: renderer.outputColorSpace = SRGBColorSpace default. toneMapping = ACESFilmicToneMapping, exposure 1.15.
Check three r170 API details:
- import * as THREE from 'three'; import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
- ConeGeometry, CylinderGeometry, DodecahedronGeometry, IcosahedronGeometry, PlaneGeometry, SphereGeometry, CircleGeometry all fine.
- PointLight(color, intensity, distance, decay). castShadow. light.shadow.mapSize.set(1024,1024). light.shadow.bias = -0.005. For point light shadow camera near defaults 0.5, far 500 — set light.shadow.camera.near=0.3, far=50.
- MeshStandardMaterial flatShading: true.
- CanvasTexture for soft particle: make a 64x64 radial gradient white center to transparent. texture.colorSpace = SRGBColorSpace? Fine for alpha mask.
Fog: scene.fog = new THREE.Fog(0x0a1220, 16, 62). Trees out to 48 will fade nicely. Sky dome radius 300 unaffected (material fog false — ShaderMaterial default is fog:false. PointsMaterial for stars set fog:false. Moon material fog:false. Glow sprites fog:false).
Ground plane size 150 so edges fade in fog before ending. Radius of circle: use CircleGeometry(80, 64)? For displacement need segments: CircleGeometry(80, 96) has only radial rings? CircleGeometry is a fan — vertices only on outer edge and center... Actually CircleGeometry(radius, segments) creates a triangle fan with vertices on circumference and center — no interior grid, cannot displace interior. Use PlaneGeometry(160,160,72,72) rotated. Corners are r≈113, way past fog far — fine, will be hidden in fog/darkness.
Trees up to 48 will be visible with fade. Fog far 62: trees at 48 are mostly fogged. Good depth cue.
Ground vertex color: create 'color' attribute: base mix. Also darken toward distance. Also slight warm ring near fire center to simulate scorching: darker brown within r<2.5.
Add a path? Skip.
Additional details: log seats — 2 bigger logs (cylinders radius .3 length 2.4) laid horizontally at r≈3.2 across fire, like seats. Nice touch. castShadow.
Star twinkle: PointsMaterial size 1.4, opacity pulse via material? Two star layers with different opacity animation: mat.opacity = 0.7+0.3*sin(t*speed). OK.
Occasional shooting star? Fun but extra. Skip to keep code tight... Could also add a small "ember trail" — skip.
Moon: position in sky visible from initial camera: camera at (9,4.5,11) looking at origin means looking toward negative direction. Moon at (-70, 55, -110) might be behind view. Place moon so it's visible on first draw: camera azimuth ~ looking from (+x,+z) toward center. What's visible is direction (-x,-z) area. Put moon at (-90, 60, -120)? In view. But autoRotate rotates camera. Moon will come in and out of view. Fine. Actually initial screenshot at 3s: autoRotate slow (0.6 → 3.6°/s), fine.
Wait, directional moonlight direction should match moon position: dirLight.position.copy(moonPos).normalize()*? Set dirLight.position to moon position. Target default origin. Intensity 0.35, color 0x9fb6ff. Slight shadows? Not necessary. Point light handles shadows. Moonlight without shadow will flatly light trees on one side — acceptable ambient. Better: make moonlight cast subtle shadow? 2 shadow maps heavy. Keep no shadow. Ambient/hemisphere provide base.
Also add a big dim fill PointLight? No.
Flame light flicker implementation: use sum of sins: n = sin(t*11.3)+sin(t*7.1+1.3)+sin(t*23.7+2.1)... intensity = 58 + n*6 + rand*2? For smoothness use deterministic sin blend plus small per-frame random jitter smoothed: lerp current to target. Good.
Ember core: IcosahedronGeometry(0.34,0) emissive 0xff5a1f, emissiveIntensity pulsing 1.5–3, color near black. Place y 0.18 inside logs.
Also charred ground: dark disc under fire: CircleGeometry(1.5) color #0d0a08 roughness 1, y=0.02 above ground center (which is flat at center) — ground is flat (amp=0 near center) so safe.
Tree color palette (night): ['#0e2a1a','#123322','#0b2317','#16392a','#0d2c20']. Slight hue variance. Trunk '#2f2013' / '#3a2817'.
Rocks gray '#545a63' with variance to '#3f4650', some darker.
Now spark color by life: f = life/maxLife. color = mix(red(1,0.25,0.05), yellow-white (1,0.85,0.4), f) with overall brightness = f for fade out. Set both attributes each frame. needsUpdate.
Firefly motion: each has center (x,z), radius small, speed, phase, height base. pos = center + (cos/sin(t*speed+phase)) * orbitR. y = base + sin(t*0.7+phase)*0.4. color brightness = max(0, sin(t*blinkSpeed+phase))^2 * greenYellow.
Count: fireflies 26.
Draw calls: trees ~52*4=208 meshes + rocks ~16 + logs ~10 + flames ~9 + ground + sky + moon + seats… ~260 draw calls. PointLight shadow cube → scene rendered 6 times for shadow casters only (casters render into shadowmap). That's ~230 casters × 6 = 1380 caster draws + main 260. Could drop frames on weak GPUs but for a demo fine. Reduce tree count to ~46 and set only trunk+foliage cast. OK. Could also make distant trees (>30) not cast shadows — nice optimization: tree.castShadow = r < 30. Do that.
Renderer: antialias true, powerPreference high-performance.
Let's write noise helpers:
function hash(n){ return fract(Math.sin(n)*43758.5453); }
function rand2(x,z){...}
groundY(x,z):
const d = Math.hypot(x,z);
const amp = smoothstep(5.5, 15, d) * 1.0; // 0 near fire
let h = Math.sin(x*0.16)*Math.cos(z*0.19)*0.55 + Math.sin(x*0.42+ z*0.31)*0.22 + Math.cos(x*0.07 - z*0.11)*0.35;
h += (rand2(x,z)-0.5)*0.5;
return h*amp;
smoothstep(e0,e1,x): t=clamp((x-e0)/(e1-e0),0,1); t*t*(3-2t).
Ground color: after displacement, compute color per vertex:
const d=hypot(x,z);
base green: pick from mix using rand:
c = lerp(#182a1c?, ...) compute with THREE.Color. Also darker with distance: multiply by (1 - clamp((d-30)/80)). Near fire (d<3): mix toward #241812 scorch, and a ring of dirt for d 2.2–3.4 toward #2a2016? Subtle.
Tree creation: makePine(scale):
trunk cylinder height 1.1. Cones: levels [ {r:1.15,h:1.5,y:1.5},{r:0.9,h:1.3,y:2.4},{r:0.62,h:1.1,y:3.2} ] approximately. Top y around 3.75. Multiply all by scale s (0.9–2.0). Total tree height ~ s*3.9 (max 7.8). Randomize cone radii ±. Slight tilt group.rotation.z = (rand-0.5)*0.06.
Place: r = 9.5 + Math.pow(Math.random(),0.85)*38 → distribution denser near clearing edge. angle uniform. Check not overlapping fire (r>9.5 guaranteed). y = groundY - 0.08.
Logs (teepee): 5 pieces: cylinder radius .11/.14, length 1.7, base positioned at radius .75 around center, leaning to touch near top center at y≈1.05. Implementation: create mesh, place pivot: move geometry so one end is at origin: geometry.translate(0, len/2, 0). Then mesh.rotation.z = tilt. Simpler: mesh.position.set(x0, 0, z0) with rotation: first rotate about x by tilt then rotate around y by angle: group per log: group.rotation.y = angle. Inside, mesh.rotation.x = -tilt (top leans toward center, meaning toward -x? After rotation.y=angle, center direction is local -x?). Simplify: place log with quaternion: dir from base point (at r=0.8, y=0.05) to apex (0,1.0,0). Cylinder by default is Y-axis. Use Object3D lookAt trick or quaternion setFromUnitVectors(Y, dirNorm), position at midpoint. Clean:
base = new THREE.Vector3(Math.cos(a)*0.85, 0.06, Math.sin(a)*0.85);
apex = new THREE.Vector3((rand-.5)*0.15, 0.95+rand*0.15, (rand-.5)*0.15);
dir = apex-base; len=dir.length(); cylinder(0.1,0.13,len,5); mesh.position.copy(base).addScaledVector(dir,0.5); mesh.quaternion.setFromUnitVectors(new Vector3(0,1,0), dir.normalize());
2 seat logs at distance ~3.4, near horizontal with slight rotation and ground follow: position y = groundY+0.28.
Ring stones: 9 at r 1.7±0.15, angle step + jitter, scale .28–.5, squash y.
Scattered rocks and stumps: 6 rocks at r 5–20 random. 3 stumps (short cylinder + top?). Keep rock count modest.
Flame cones: define layers:
outer: 6 cones at r .30–.42, h 1.0–1.3, color 0xff5a1e..0xff8b2e, opacity .75
inner: 4 cones at r .16–.22, h .6–.8, color 0xffc23e..0xffdd7a, opacity .9
core: 1 cone r .1 h .45 color 0xfff3c4.
Random offset each within .12 radius. Animate: s = 0.85 + 0.3*sin(t*sp + ph) + 0.1*sin(t*sp2); mesh.scale.y = s. scale.x=scale.z = 0.9+0.15*sin(t*...); position.x = ox + 0.03*sin(t*3+ph); rotation.y += dt*rs. Also group.scale overall flicker.
Materials additive, depthWrite false, transparent true. renderOrder after fire stuff. Fog affects? flame material with fog:true default — fire is close. OK.
Glow sprite: pos (0,1.1,0), scale 5.5, color 0xff7a2e, opacity base .35 pulses with light intensity factor.
Spark texture: canvas 64: gradient rgba(255,255,255,1)→0.
Sparks Points geometry with attributes position(3*N), color(3*N). PointsMaterial({size:.11, map:softTex, vertexColors:true, transparent:true, depthWrite:false, blending:AdditiveBlending, sizeAttenuation:true}).
Smoke sprite array of {sprite, t0 offset, speed, drift}. Cycle period ~6s: p = ((t*speed + off) % 1); y = 1.3 + p*5.5; scale = .6 + p*3; opacity = sin(p*π)*0.14 (fades in/out); x = drift * p + sway; material.rotation per sprite static random (SpriteMaterial rotation settable). Use separate materials (clone) since opacity differs.
Moon mesh: SphereGeometry(7, 16, 16) basic color 0xe8eefc slight emissive? MeshBasicMaterial color 0xdfe8ff, fog:false. Add craters? Skip. Glow sprite scale 42 color 0x9fb6ff opacity .5? Use gradient texture. Additive.
Stars: 900 points radius 260..300: random direction on sphere (upper hemisphere mostly y>0.02): generate vec3 random normal, normalize, if y<0 flip. Multiply radius. Colors slightly varying white/blue. PointsMaterial size 1.6, sizeAttenuation false? If false, size is pixels — stars constant pixel size good. size 1.6/2.2 for 2 layers. vertexColors true subtle tint. fog:false, transparent, opacity animated.
Sky dome shader:
uniforms: topColor #030614? Let's make: horizon #101b33, zenith #02040c. Subtle warm tint at horizon near fire? Skip.
ShaderMaterial(side:BackSide, depthWrite:false, fog:false).
varying world y normalized: h = normalize(vWorldPosition).y; color = mix(horizon, zenith, pow(max(h,0),0.6)). Below horizon, darken.
Mesh renderOrder = -1 to render first? Fine with depthWrite false and big sphere. Set scene.background = 0x02040c as fallback anyway.
Fog color must roughly match horizon: 0x0d1526.
Check: fog fades trees to that color. Sky at horizon is #101b33 — close enough for blend.
Also add subtle blue "moon rim" on ground? directional light covers.
Title overlay:
<div class="ui">
<div class="brand"><span class="kicker">THREE.JS · NIGHT STUDY</span><h1>The Clearing</h1><p>low-poly campfire under a paper moon</p></div>
</div>
<div class="hint">drag to look around · scroll to zoom</div>
<div class="vignette"></div>
Styling: h1 serif (Georgia) large amber with soft text-shadow. kicker letter-spacing uppercase small warm gray. Hint bottom center small, fade-in, opacity .55. pointer-events none on everything.
Font: display Georgia, 'Times New Roman', serif. Body/hint: system-ui.
Maybe fade the hint out after user interacts? Nice: on first pointerdown, add class to fade the hint. Small JS.
Also handle pointerdown to stop autoRotate.
Edge: iframe keyboard — OrbitControls uses pointer events mostly. Keyboard not required. No click prompt needed, since interaction is mouse. Instructions say to show click/press message only if demo uses keyboard — I won't rely on keyboard. Show hint anyway.
Now performance and correctness details:
renderer.setAnimationLoop(animate) — good.
dt = Math.min(clock.getDelta(), 0.05); t = clock.elapsedTime.
Spark update code:
const N=150; arrays pos Float32Array(N*3), col, vel Float32Array(N*3), life Float32Array(N), maxLife.
Initialize life randomly offset so continuous stream.
reset(i): x=(r()-.5)*.5, z same, y=0.4+r()*0.3; vel: vx=(r()-.5)*.5, vy=1.3+r()*1.6, vz same; maxLife=life=1.2+r()*1.6.
Update: life-=dt. If <0 reset. pos += vel*dt; add slight vortex: vx += sin(t*3+i)*0.15*dt? Also buoyancy vy += 0.4*dt? Also inward pull? Sparks converge slightly: vx += -x*0.4*dt.
f=life/maxLife; brightness= f*f? Color: r=1, g=0.35+0.55*f, b=0.08+0.3*f^2 multiplied by brightness.
Set attribute needsUpdate.
Fireflies: Points N=26 similar structure, color: base (0.62,1.0,0.45)*b, b=pow(max(0,sin(t*bs+ph)),3). Also blink off occasionally: fine. Positions wandering: x = cx + cos(t*sp+ph)*rr, z = cz + sin(t*sp*0.9+ph)*rr, y = cy + sin(t*0.6+ph*2)*0.5. cx,cz random ring 4..15, cy=1..2.6.
PointsMaterial size .22, additive, map soft.
Light flicker:
n = Math.sin(t*10.7)*0.5 + Math.sin(t*17.3+1.7)*0.3 + Math.sin(t*29.1+4.2)*0.2; // -1..1
target = 62 + n*14 + (Math.random()*4);
light.intensity += (target-light.intensity)*Math.min(1, dt*12);
light.position.set((r()-.5)*0.1, 1.05 + n*0.05, ...): use sin-based for stability: x=Math.sin(t*7.7)*0.07 etc.
Glow sprite opacity = 0.3 + (light.intensity-62)/62*0.4 clamp 0.2..0.5. material.opacity set. Scale pulse too.
Ember emissiveIntensity = 1.8 + n*0.8.
Directional moon: intensity 0.3.
HemisphereLight(0x2a3a5f, 0x0b0f0a, 0.5).
Ambient probably unnecessary.
Tone: exposure 1.2.
Shadow tuning: pointLight.shadow.camera.near=0.4, far=45; bias -0.006? For point light slight negative bias. Or normalBias 0.02? Let's set bias:-0.004, mapSize 1024.
Ground receiveShadow true. Rocks/logs/seats cast+receive. Trees cast (near) receive false.
Flames no shadow.
Check OrbitControls maxPolarAngle: PI/2 - 0.06 keeps above horizon at target height 1.2. Camera distance min 4.5 → camera y at horizon min = target.y + dist*cos(polar), at polar max ~ cos(1.51)=0.06 → y=1.2+0.27=1.47 above ground fine. Ground bump up to ~? At distance >15 bump max ~0.8, camera when polar max at radius near center is fine. Also minPolarAngle 0.12 to avoid gimbal weirdness at top-down. minPolarAngle = 0.15.
controls.target.set(0,1.1,0).
Initial camera position: (8.5, 4.2, 10.5) — distance ~13.9, within max 40 fine.
autoRotateSpeed = -0.5 (slow cinematic), disabled on interaction via 'start'.
Resize: also update pixel ratio on resize.
Sky shader code:
const skyGeo = new THREE.SphereGeometry(300, 24, 12);
const skyMat = new THREE.ShaderMaterial({
side: THREE.BackSide, depthWrite:false, fog:false,
uniforms:{ top:{value:new THREE.Color(0x020409)}, mid:{value:new THREE.Color(0x0a1430)}, bot:{value:new THREE.Color(0x101b2e)} },
vertexShader: varying vec3 vP; void main(){ vP = position; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }
fragment: varying vec3 vP; uniform vec3 top,mid,bot; void main(){ float h = normalize(vP).y; vec3 c; if(h>0.0){ c = mix(mid, top, pow(h, 0.55)); } else { c = mix(mid, bot, pow(-h,0.5)); } gl_FragColor = vec4(c,1.0); }
});
Colors: zenith #01030a deep, mid #0a1430, horizon below #05070d. Fog color 0x0a1220 ~ mid. Good.
Hmm, stars radius 300 = same as sky sphere 300 → z-fight at edge. Stars 285 inside? Sky drawn on back face at 300. Stars inside at 280 fine, depth: sky depthWrite false, but render order: sky should render first. Set sky.renderOrder=-2, stars renderOrder -1, transparents default 0. Points are transparent? starMat transparent true. Sort: transparent objects sorted by depth. OK, just set sky.renderOrder = -10 to ensure it renders first in transparent pass? renderOrder overrides sorting. Set sky.renderOrder=-1. starMat.depthWrite false too. Moon glow also additive depthWrite false.
Simplify: sky.renderOrder=-1.
Sky sphere at 300 with camera far: set camera far to 800.
Fog far 62 vs ground size 170: corners distance 120 → fully fogged to fog color which matches sky bottom ~ good.
Another ambient touch: occasional bigger spark? Random resets occasionally yield bigger? PointsMaterial single size… skip.
Firefly color green maybe too saturated. Use warm yellow-green (0.85,1.0,0.5).
Also small "heat distortion"? no.
UI text content:
kicker: "LOW-POLY NIGHT STUDY"
h1: "The Clearing" maybe with a styled ampersand. Title text: "The Clearing" sub: "a campfire among the pines — three.js r170". Hint: "drag to orbit · scroll to zoom · pinch on touch".
Font: display: Georgia, 'Iowan Old Style', 'Times New Roman', serif. Body/hint: system-ui stack.
Let's write final HTML. Also handle context menu? no.
Code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Clearing — low-poly campfire</title>
<style> ... </style>
<script type="importmap">…</script>
</head>
<body>
<div id="ui">…</div>
<div class="hint" id="hint">…</div>
<div class="vignette"></div>
<div class="grain"?> skip
<script type="module"> … </script>
</body></html>
Note: importmap script must be in HTML as given.
Let me write JS carefully.
const canvas? Let renderer create canvas and append.
Full code sketch:
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// helpers
const fract = x => x - Math.floor(x);
const rand2 = (x,z) => fract(Math.sin(x*127.1 + z*311.7) * 43758.5453);
const smoothstep = (a,b,x)=>{const t=THREE.MathUtils.clamp((x-a)/(b-a),0,1);return t*t*(3-2*t);};
const rand=(a=1,b)=> b===undefined ? Math.random()*a : a+Math.random()*(b-a);
const pick = arr => arr[(Math.random()*arr.length)|0];
function groundY(x,z){
const d=Math.hypot(x,z);
const amp=smoothstep(5.5,16,d);
let h=Math.sin(x*0.16)*Math.cos(z*0.19)*0.55
+Math.sin(x*0.42+z*0.31)*0.22
+Math.cos(x*0.07-z*0.11)*0.3
+(rand2(x,z)-0.5)*0.45;
return h*amp;
}
Soft texture:
function makeGlowTexture(){ canvas 64; radial gradient center white 1 → 0; return new THREE.CanvasTexture(c); }
renderer setup...
scene, fog = new THREE.Fog(0x0a1220, 16, 62);
camera far 800.
Sky, stars (2 layers), moon + glow, directional moonlight.
Lights: hemi, moon dir, fire point.
Ground build:
const gGeo=new THREE.PlaneGeometry(170,170,90,90); gGeo.rotateX(-Math.PI/2);
positions loop: y=groundY(x,z). Color: compute.
const cA=new THREE.Color(0x182f1f), cB=new THREE.Color(0x0d1a12), scorch=new THREE.Color(0x1c130c), dirt=new THREE.Color(0x2a2016);
For each vertex: t=rand2(x+9.7,z-4.3); col = cB.clone().lerp(cA, t*0.9); distance shade: mult = 1 - smoothstep(20,70,d)*0.55; col.multiplyScalar(mult);
if(d<2.1) col.lerp(scorch, 1-d/2.1);
else if(d<3.6) col.lerp(dirt, 0.35*(1-(d-2.1)/1.5));
set color.
computeVertexNormals? With flatShading true, normals computed flat per-face in shader? flatShading uses derivatives — no need for normals. Still call computeVertexNormals for standard material safety.
mat = MeshStandardMaterial({vertexColors:true, flatShading:true, roughness:1, metalness:0});
mesh receiveShadow.
Grass tufts: cone geometry(0.05,0.3,4) — instanced 180.
const inst = new THREE.InstancedMesh(geo, mat, count). Dummy Object3D for setting matrix. Random position r 2.8..24 (avoid center path), color per instance: darker greens. mesh.instanceColor via setColorAt. castShadow false, receiveShadow maybe.
Rocks: dodecahedron geometry with random scale/rotation, gray, flatShading.
Fire assembly:
stone ring: 9 dodecahedrons.
charred disc: circle r1.6, color 0x120c08, y0.02? Ground near center flat at 0. y=0.03, rotation -PI/2, polygonOffset? Just y 0.03 above is fine.
ember core.
teepee logs (5).
fallen logs 2.
flames group.
glow sprite.
point light.
Trees:
Tree loop: create pine, push sway.
Sparks system, smoke sprites, fireflies.
Controls + events.
Animate loop with all updates.
Double check: MeshStandardMaterial flatShading with vertexColors — fine.
InstancedMesh grass color: use setColorAt then instanceColor.needsUpdate. Material color white so vertex/instance colors come through.
Grass positions avoid fire: r>3.
Seat logs: cylinder r0.26 len 2.3, bark 0x33241a, horizontal rot z PI/2, then rotation.y random, pos r 3.3, y ground+0.24, castShadow. Also a flat rock seat? Enough.
Stump: 1 cylinder r .3 h .5 6 seg, top cap brighter? Skip cap.
Add subtle bluish backlight rim? no.
Moon position: (-85, 62, -120). Camera at (8.5,4.2,10.5) looking toward origin will see direction ~(-0.55,-0.25,-0.7) → moon az atan2(-120,-85): behind-left and high 62: elevation from origin fine. In view? Camera looks from (8.5,4.2,10.5) toward (0,1.1,0): forward = normalize(-8.5,-3.1,-10.5) ≈ (-0.6,-0.22,-0.75). Moon dir from camera ≈ (-93.5,57.8,-130.5) normalized (-0.55,0.34,-0.76). Dot with forward ≈ 0.33+(-0.075)+0.57=0.83 → ~34° angle, FOV 55 (vertical 55. Horizontal ~ wider ~ 80 for 16:10). Vertical angle: forward -0.22 vs moon +0.34: angle between ≈ cos⁻¹(0.83)=34°, probably within horizontal fov. Likely visible in top area.
Also autoRotate may move slightly in 3s: 0.5 speed → 3°/s*3=9° orbit — fine.
Star twinkle opacity: starMat1.opacity=0.75+0.25*sin(t*1.7); layer 2 with cos offset.
Fire point light shadow: trees beyond r 30 don't cast (perf) — also shadow far 45 covers them anyway.
PointLight shadow bias: use shadow.bias=-0.004 and shadow.normalBias=0.02? normalBias helps for point lights too. Let's use small bias -0.002 and normalBias 0.05? Keep bias -0.004, skip normalBias maybe... slight acne on ground could show. Let me try bias -0.005.
Set renderer.shadowMap.enabled=true, type=PCFSoftShadowMap.
Also add small animation: stone ring no.
Smoke color: 0x9aa3b5, low opacity. Blending Normal. depthWrite false. renderOrder 3.
Verify sparks: reset positions y start ~0.5..0.9 within flame.
Also add subtle orange PointLight for ember second one? No.
Now UI fade: after first pointerdown, ui class 'focused' → hint opacity 0 transition.
Edge: iOS pinch: OrbitControls handles touch.
Let me now write the final code carefully, minding syntax errors.
For star colors: slight variation white→bluish. Use vertexColors true and color attribute.
Star positions: random on sphere upper:
v = new THREE.Vector3(rand(-1,1), rand(0.05,1), rand(-1,1)).normalize() ... y positive only: if y<0.05 take abs. Multiply radius rand(265,285).
PointsMaterial({size:2.1, sizeAttenuation:false, vertexColors:true, transparent:true, opacity:0.9, fog:false, depthWrite:false}).
Size pixels are multiplied by pixelRatio? Points size in gl_PointSize is pixels — with renderer pixel ratio 2 it looks smaller visually but consistent. OK.
Firefly texture use softTex map, with alphaTest? Additive fine.
Now flame cone geometry translate: geo.translate(0,h/2,0) so base is at 0.
Flame animate code per cone userData {ph, sp, bs (base scaleY=1), ox, oz, rs}.
mesh.scale.y = 0.8+0.28*Math.sin(t*sp+ph)+0.12*Math.sin(t*sp*2.7+ph*1.7);
clamp positive min 0.3.
sx = 1 + 0.18*Math.sin(t*sp*1.3+ph*2.0);
mesh.scale.x=mesh.scale.z=sx;
mesh.position.x = ox + 0.04*Math.sin(t*2.3+ph);
mesh.position.z = oz + 0.04*Math.cos(t*2.1+ph);
mesh.rotation.y = t*rs + ph;
Group position y = 0.32 (top of logs/embers) maybe 0.3.
Light flicker ties: use shared flicker value fl = 0.5+0.5*(sin(t*10.7)*0.5+sin(t*17.3+1.7)*0.3+sin(t*29.1+4.2)*0.2); range ~0..1.
light.intensity = 40 + fl*45; // 40..85
light.position.x = Math.sin(t*7.3)*0.08; z=Math.cos(t*6.1)*0.08; y=1.0+fl*0.15;
glow.material.opacity = 0.22+fl*0.2;
glow.scale.setScalar(5.2+fl*1.2);
ember.material.emissiveIntensity = 1.6+fl*1.6;
Flame group scale.y also = 0.9+fl*0.25.
Smoke spawn y depends on flame tip ~1.4.
Spark spawn y 0.4..0.8.
I think we're ready to write. Also include a <noscript>? Skip.
Let me confirm no `Math.random` in groundY (deterministic) — yes, we use rand2 hash, but (rand2-0.5) term is deterministic per vertex. Same call for object placement gives same height. Good.
Note: groundY used with continuous x,z. Hash noise gives high-freq jitter which makes flat-shaded facets crisp — good.
Ground displacement loop:
const pos=gGeo.attributes.position; const colors=[]
for(i=0;i<pos.count;i++){x=pos.getX(i); z=pos.getZ(i); y=groundY(x,z); pos.setY(i,y); ...color}
After rotateX, PlaneGeometry lies in XZ plane with y up: PlaneGeometry created in XY. rotateX(-90) maps y→z? Vertex (x,y,0) → after rotation about X by -PI/2: (x, 0*?, ...). Rotation -90 about X: y' = y*cos(-90) - z*sin(-90) = z? With z=0: y'=0... z' = y*sin(-90)+z*cos(-90) = -y. So new position (x, 0, -y_old). So getX is x, getZ is z. Yes, works with setY.
Color attr: gGeo.setAttribute('color', new THREE.BufferAttribute(new Float32Array(pos.count*3),3)); fill.
computeVertexNormals after displacement.
Instanced grass colors: mesh.setColorAt requires material with vertexColors? Use instanceColor automatically when setColorAt is used. Material color multiplies. Set material color 0xffffff and set instance colors dark green.
Grass geometry: ConeGeometry(0.06, 0.34, 4). Translate up 0.17 so base at 0.
Random tilt in dummy.rotation.
Positions: r = 3 + Math.pow(Math.random(),0.7)*22, angle. Skip if too close to seat logs — doesn't matter.
y = groundY.
Also add small mushrooms? Skip.
Final check OrbitControls import path with importmap "three/addons/" → OrbitControls resolves.
Now let's write the code, carefully.
Also remember: no text before/after code block. Answer exactly one fenced block.
Let me write everything concretely now.
const TAU = Math.PI*2;
Flame material creation helper:
function flameMat(color, opacity){ return new THREE.MeshBasicMaterial({color, transparent:true, opacity, blending:THREE.AdditiveBlending, depthWrite:false, side:THREE.DoubleSide}); }
DoubleSide so cone interior visible when looking through the top opening. Additive with double may double brightness — fine.
Flame layer config:
outer: 6: {r: rand(0.26,0.4), h: rand(0.9,1.35), color pick [0xff4d12,0xff6a1a,0xff8426], op 0.65}
mid: 4: {r rand(0.15,0.22), h rand(0.55,0.8), colors 0xffb13a,0xffc94d, op 0.85}
core: 2: r 0.09 h 0.45 color 0xfff0b0 op 0.95
Random offset within radius 0.1 (outer), 0.05 inner.
Cone radialSegments 6.
Teepee log colors: bark 0x3b2a1a, some darker 0x2c1f13.
OK, final write.
One more consideration: initial screenshot at 3s — autoRotate on, flames animated, sparks visible, glow visible, moon maybe visible, title visible.
Also `renderer.setPixelRatio(Math.min(devicePixelRatio, 2))`.
Potential console errors: none. OrbitControls dispatches 'start' 'change' 'end'.
Hint fade on pointerdown: window.addEventListener('pointerdown', once → hint.classList.add('hide'); controls.autoRotate=false). Also controls 'start' same — do it on controls.addEventListener('start').
Let's write CSS:
html,body{margin:0;height:100%;overflow:hidden;background:#04060d;}
canvas{display:block}
#ui{position:fixed;top:26px;left:30px;z-index:5;pointer-events:none;user-select:none}
.kicker{font:600 11px/1 system-ui; letter-spacing:.34em; color:#8b93a8; text-transform:uppercase; margin-bottom:10px; opacity:.85}
h1{margin:0; font-family:Georgia,'Times New Roman',serif; font-weight:600; font-style:italic; font-size:clamp(30px,4.2vw,52px); color:#ffc57e; text-shadow:0 0 26px rgba(255,140,50,.4), 0 2px 18px rgba(0,0,0,.6);}
.sub{margin-top:8px;font:400 13px/1.5 system-ui; color:#a7aec0; max-width:34ch}
.hint{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:5;pointer-events:none;font:500 12px/1 system-ui;letter-spacing:.18em;text-transform:uppercase;color:#c8925a;opacity:.8;padding:9px 18px;border:1px solid rgba(255,160,80,.25);border-radius:999px;background:rgba(10,8,6,.35);transition:opacity .8s; white-space:nowrap}
.hint.hide{opacity:0}
.vignette{position:fixed;inset:0;z-index:3;pointer-events:none;background:radial-gradient(ellipse at 50% 42%, transparent 52%, rgba(2,3,8,.62) 100%)}
@media(max-width:640px){ .sub{display:none} #ui{left:20px;top:20px} }
Add a small animation to title (fade in) for a bit of life: @keyframes rise {from{opacity:0;transform:translateY(8px)} to{}} applied to #ui > * with delays.
Now let me finalize JS numbers and write it out. Need to double-check all variable definitions.
Smoke: create 7 sprites.
function makeSoftTexture(){ const c=document.createElement('canvas'); c.width=c.height=64; const ctx=c.getContext('2d'); const g=ctx.createRadialGradient(32,32,0,32,32,32); g.addColorStop(0,'rgba(255,255,255,1)'); g.addColorStop(0.35,'rgba(255,255,255,0.6)'); g.addColorStop(1,'rgba(255,255,255,0)'); ctx.fillStyle=g; ctx.fillRect(0,0,64,64); const t=new THREE.CanvasTexture(c); return t;}
Sparks reset function uses index arrays.
Fireflies arrays.
Tree sway rotation: group.rotation.z set base tilt + sway — but group.rotation.y also used for orientation. Tilt on z after y rotation fine.
Store sway items {g:group, ph, base:tilt}.
Animate: it.g.rotation.z = it.base + Math.sin(t*0.55+it.ph)*0.015.
Time via clock.elapsedTime captured once per frame.
PointLight: const fireLight=new THREE.PointLight(0xff8433, 60, 0, 2); position 0,1.05,0; castShadow=true; shadow.mapSize 1024; shadow.bias=-0.004; shadow.camera.near=0.3; far=45.
Also faint secondary ember light? No.
Hemi: new THREE.HemisphereLight(0x28355a, 0x0a0e08, 0.55).
Moon dir light: 0x93a7e8? color 0x8fa5dd intensity 0.35 position moonPos.
Also dim ambient AmbientLight(0x10141f, 0.6)? Hemi covers. Skip.
Exposure 1.15.
Check additive glow sprite over ACES: fine.
Star material fog:false so stars visible through fog (in sky).
scene.add all.
Spark colors: use THREE.Color tmp for setting.
Write update loop parts in order.
OK — also check no NaN in `rand2`: sin big number fine.
One subtle thing: PlaneGeometry 170 size with 90 segments = 91*91=8281 vertices loop fine.
Trees: also avoid trees on moon-side path? No.
Also requirement "renderer fills the window" — body margin 0 canvas block 100%.
Now let me produce final answer. Just one fenced html block.
Let me write out the full file.
Additional checks: OrbitControls maxPolarAngle with target y 1.1 and minDistance 4.5: camera at polar max, distance 4.5 → y = 1.1 + 4.5*cos(86.5°)=1.1+0.27=1.37 > ground bump near center (which is ~0 there), good. At big distance 40: y=1.1+2.3=3.4, ground at r~40 bump up to ~0.8, fine.
minPolarAngle 0.12.
controls.enablePan = true but screenSpacePanning false to keep target near ground. Restrict target? Simple: enablePan true. maxTargetRadius? r170 has controls.maxTargetRadius? OrbitControls added `maxTargetRadius` in r167? exists (maxTargetRadius added ~r167). To be safe, I'll skip and just disable pan to keep target near ground — requirement is just look around. Or leave pan on with screenSpacePanning=false which pans in plane perpendicular to up, so target y stays 1.1. Good: controls.screenSpacePanning=false (default is actually true?). Default screenSpacePanning = true. Set false.
OK, let me finalize flame base position: logs apex ~0.95. Flame base y ~0.35 above ground, inside teepee. Outer flame heights up to 1.35*1.25 scale ≈ 1.7 → tip ~2.0. Nice fire.
Sparks spawn up to y 0.9.
Glow center y 1.0.
Charred disc radius 1.7.
Stone ring r 1.75.
Let's write.
Also verify additive material `transparent:true`.
SpriteMaterial rotation: set once random.
Smoke update:
smoke.forEach(s=>{ const p = fract(t*s.speed + s.off); const y=1.2+p*5.2; s.sprite.position.set(Math.sin((p*4+s.off*9))*0.4 + p*s.wind, y, Math.cos(p*3.3+s.off*7)*0.4); const sc=0.7+p*3.4; s.sprite.scale.set(sc,sc,1); s.sprite.material.opacity = Math.sin(p*Math.PI)*0.12*(1-p*0.2); s.sprite.material.rotation += dt*0.15*s.dir; });
speed ~0.12 (period ~8s). off random.
Firefly update colors:
b = Math.pow(Math.max(0, Math.sin(t*f.blink + f.ph)), 3) * f.amp;
if b<0.02 hide? color near black → invisible with additive. good.
color.setRGB(0.72*b, 1.0*b, 0.45*b);
Size .2 map soft additive depthWrite false.
Positions around r 4..15 → maybe in trees too. Fireflies near trees fine.
Star twinkle: starMatA.opacity = 0.65+0.3*Math.sin(t*1.3); starMatB.opacity=0.65+0.3*Math.sin(t*1.7+2.1);
Moon glow: sprite scale 46 opacity .5? Additive with gradient could blow out sky. Use color 0x7d93d9 opacity .45, plus inner bright glow at moon scale 18 white 0.9? Layer: glowOuter scale 55 op .35 color 0x5b74c9. Let's just do one glow: scale 48, color 0x8ea6e8, opacity .5. Moon itself basic 0xe6ecfb.
OK writing final.
Also `renderer.toneMapping = THREE.ACESFilmicToneMapping;`.
`scene.background = new THREE.Color(0x04060d);` as fallback behind sky.
Now let's generate the file. Aim for clean, lightly commented code.
Grass InstancedMesh:
const gCount=200; const grassGeo=new THREE.ConeGeometry(0.055,0.36,4); grassGeo.translate(0,0.18,0);
const grassMat=new THREE.MeshStandardMaterial({color:0xffffff, flatShading:true, roughness:1});
const grass=new THREE.InstancedMesh(grassGeo,grassMat,gCount); grass.receiveShadow=false; grass.castShadow=false;
const dummy=new THREE.Object3D(); const gc=new THREE.Color();
loop: r=2.9+Math.pow(Math.random(),0.75)*21; a=rand TAU; x=cos*r etc. dummy.position.set(x, groundY(x,z)-0.02, z); dummy.rotation.set((Math.random()-.5)*0.35, Math.random()*TAU, (Math.random()-.5)*0.35); s=rand(0.7,1.6); dummy.scale.set(s,s*rand(0.9,1.6),s); dummy.updateMatrix(); grass.setMatrixAt(i,dummy.matrix); gc.setHSL(0.36+Math.random()*0.05, 0.45, 0.10+Math.random()*0.08); grass.setColorAt(i,gc);
grass.instanceColor.needsUpdate=true;
Tree function:
function makePine(){
const g=new THREE.Group();
const s=rand(0.85,2.0);
const trunkMat = trunkMatShared; // share material for perf
trunk geo shared? Geometry per size... use unit geometry and scale via group. Use unit geometry: trunk Cylinder(0.16,0.24,1.1,5) translated y .55. Cones unit r1 h1 translated? Scale cones individually by radius and height.
Simplify: create geometry per tree with actual sizes — 48 trees × 4 geometries = 192 geometries, fine but memory. Better share unit geometries and scale meshes:
const trunkGeo=new THREE.CylinderGeometry(0.16,0.26,1,5); trunkGeo.translate(0,0.5,0);
const coneGeo=new THREE.ConeGeometry(1,1,6); coneGeo.translate(0,0.5,0);
Share across trees (defined once).
Meshes scale accordingly:
trunk: scale.set(1, 1.1*s? but radial also scaled... scale.set(ts, th, ts).
Foliage levels (before group scale s):
L1: radius 1.25, height 1.7, base y 0.9
L2: radius 0.95, height 1.45, base y 1.75
L3: radius 0.62, height 1.2, base y 2.5
Tip ~3.7. Trunk height 1.0 radius .16/.26.
Mesh scale.set(radius, height, radius), position.y=baseY.
Group scale s → total height 3.7*s (max 7.4).
Foliage material: pick from palette array of MeshStandardMaterial (flatShading) shared. Slight emissive? No.
castShadow: trunk.castShadow = foliage.castShadow = (dist<32);
}
Slight color variance per tree: pick from 5 materials.
Tree count: 50.
Also 6 bigger rocks + 3 stumps scattered.
Stump: cylinder(0.28,0.34,0.5,6) with bark material, positioned.
Also add fallen mossy boulder? Enough.
Time to write final file.
Let me re-check importmap syntax:
<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>
Module script imports OrbitControls from 'three/addons/controls/OrbitControls.js'.
Final animate:
function animate(){
const dt=Math.min(clock.getDelta(),0.05); const t=clock.elapsedTime;
// flicker
const fl = 0.5 + 0.5*(Math.sin(t*10.7)*0.5 + Math.sin(t*17.3+1.7)*0.3 + Math.sin(t*29.1+4.2)*0.2);
fireLight.intensity = 42 + fl*46;
fireLight.position.set(Math.sin(t*7.3)*0.08, 1.0+fl*0.18, Math.cos(t*6.1)*0.08);
glow.material.opacity = 0.2+fl*0.22; glow.scale.setScalar(5+fl*1.4);
emberMat.emissiveIntensity = 1.5+fl*1.8;
flameGroup.scale.y = 0.9+fl*0.22;
flames.forEach(f=>{...});
sparks update; smoke; fireflies; trees sway; star twinkle;
controls.update(); renderer.render(scene,camera);
}
renderer.setAnimationLoop(animate);
Sparks color update loop is index-based with Float32 arrays.
OK let's write final. Also don't forget `const clock=new THREE.Clock();`.
Resize:
function onResize(){ camera.aspect=innerWidth/innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth,innerHeight); renderer.setPixelRatio(Math.min(devicePixelRatio,2)); }
addEventListener('resize',onResize);
Controls events:
controls.addEventListener('start', ()=>{ controls.autoRotate=false; hint.classList.add('hide'); });
Initial camera position, then controls.update().
I think we're done. Let me write it out.