Kimi K3.1 Pro
by MoonshotAIreasoning defaultClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 3 min 50 s
- Cost
- $0.534
- Output tokens
- 39,398
- Spent thinking
- 26,004 tokens
- Speed
- 171.2 tok/s
- Console errors
- 0
- First token after
- 333 ms
- First visible text after
- 2 min 60 s
- Prompt tokens
- 643
- Code size
- 44.4 KB
- Finish reason
- stop
- Served by
- InferenceNet
- Model id
moonshotai/kimi-k3.1-pro- Reasoning sent
model default- Max output
- 943,718 tokens
- Generated
- Nov 12, 2026
What it was asked
The prompt
Build a low-poly 3D scene of a campfire in a forest clearing at night, with Three.js in a single HTML file. Load Three.js only through this import map (do not use any other URL or version): <script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"}}</script> Requirements: - A ground with a few low-poly rocks and logs around a campfire in the center. - At least 30 low-poly trees placed randomly around the clearing (not on the fire). - The campfire has animated flames and a warm point light that flickers and casts shadows. - Sparks rising from the fire as a simple particle system. - A dark night sky with stars and a moon, plus fog that fades the distant trees. - OrbitControls to look around, with limits so the camera stays above the ground. - The renderer fills the window and handles resizing correctly.
System prompt, sent with every prompt
You are generating a self-contained web demo. It will be rendered automatically in a sandboxed iframe and compared side by side with the output of other AI models given the exact same task. Output rules: - Reply with exactly ONE fenced code block containing the complete file: ```html (or ```svg when the task asks for an SVG). No text before or after the code block. - Everything must be inline: CSS in <style>, JavaScript in <script>. No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task. - Do not use localStorage, sessionStorage, cookies, IndexedDB, alert(), confirm(), prompt() or window.open(): they are blocked in the sandbox and will throw. - The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar. - Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction. - Everything that makes the demo interesting must be visible within the first seconds: nothing hidden behind a high score, a later level or a long play session. - Games must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" message, so the game can be judged in action without playing. - The iframe only receives keyboard events after the user clicks it: if the demo uses the keyboard, display a clear "Click or press a key to start" message. - The code must run with zero errors in the console of the latest Chrome.
Code run-1.html · 1446 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Emberwood — Low-Poly Night Clearing</title>
<style>
:root {
color-scheme: dark;
--cream: #f3e8d2;
--muted: #a8b4b5;
--ember: #f2b56b;
--glass: rgba(7, 14, 20, 0.48);
--line: rgba(223, 236, 232, 0.13);
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: #04070d;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
body {
user-select: none;
}
#scene {
position: fixed;
inset: 0;
}
#scene canvas {
display: block;
width: 100%;
height: 100%;
outline: none;
touch-action: none;
}
.vignette {
position: fixed;
inset: 0;
z-index: 1;
pointer-events: none;
background:
linear-gradient(to bottom, rgba(1, 4, 9, 0.24), transparent 23%, transparent 74%, rgba(1, 4, 7, 0.26)),
radial-gradient(circle at 50% 52%, transparent 35%, rgba(0, 2, 6, 0.13) 72%, rgba(0, 2, 6, 0.48) 120%);
}
.ui {
position: fixed;
inset: 0;
z-index: 2;
pointer-events: none;
color: var(--cream);
}
.brand {
position: absolute;
top: 30px;
left: 34px;
max-width: calc(100vw - 68px);
text-shadow: 0 2px 20px rgba(0, 0, 0, 0.5);
}
.eyebrow {
display: flex;
align-items: center;
gap: 9px;
margin-bottom: 10px;
color: #c2ceca;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.2em;
}
.live-dot {
width: 6px;
height: 6px;
flex: 0 0 auto;
border-radius: 50%;
background: var(--ember);
box-shadow: 0 0 12px rgba(242, 181, 107, 0.85);
animation: breathe 2.4s ease-in-out infinite;
}
@keyframes breathe {
0%, 100% { opacity: 0.55; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1.15); }
}
h1 {
margin: 0;
font-size: clamp(30px, 3.1vw, 45px);
font-weight: 300;
line-height: 1;
letter-spacing: -0.045em;
}
.brand p {
margin: 12px 0 0;
color: var(--muted);
font-size: 12px;
font-weight: 400;
letter-spacing: 0.015em;
}
.help-panel {
position: absolute;
top: 30px;
right: 30px;
width: 210px;
padding: 16px 17px 14px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--glass);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.16);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.help-title {
margin-bottom: 12px;
color: #d6dfda;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 9px;
letter-spacing: 0.19em;
}
.help-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 15px;
padding: 6px 0;
color: #b7c3c1;
font-size: 11px;
}
.help-row kbd {
min-width: 50px;
padding: 4px 6px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 5px;
background: rgba(255, 255, 255, 0.045);
color: #e5ded0;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 8px;
font-weight: 500;
text-align: center;
letter-spacing: 0.08em;
box-shadow: none;
}
.bottom-bar {
position: absolute;
right: 28px;
bottom: 24px;
left: 28px;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 20px;
}
.scene-stats {
display: flex;
align-items: center;
gap: 10px;
justify-self: start;
color: rgba(199, 211, 207, 0.68);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 9px;
letter-spacing: 0.14em;
}
.scene-stats .separator {
width: 3px;
height: 3px;
border-radius: 50%;
background: rgba(242, 181, 107, 0.75);
}
.orbit-hint {
justify-self: center;
color: rgba(215, 224, 219, 0.67);
font-size: 10px;
letter-spacing: 0.08em;
text-shadow: 0 2px 12px #000;
white-space: nowrap;
}
.mobile-hint {
display: none;
}
.actions {
display: flex;
gap: 8px;
justify-self: end;
pointer-events: auto;
}
button {
appearance: none;
padding: 9px 13px;
border: 1px solid var(--line);
border-radius: 9px;
background: rgba(7, 14, 20, 0.55);
color: #dce3dd;
font: inherit;
font-size: 10px;
letter-spacing: 0.035em;
white-space: nowrap;
cursor: pointer;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
transition: background 160ms ease, border-color 160ms ease, transform 160ms ease;
}
button:hover {
border-color: rgba(242, 181, 107, 0.38);
background: rgba(26, 29, 28, 0.72);
transform: translateY(-1px);
}
button:active {
transform: translateY(0);
}
button:focus-visible {
outline: 2px solid rgba(242, 181, 107, 0.8);
outline-offset: 3px;
}
@media (max-width: 760px) {
.brand {
top: 22px;
left: 22px;
max-width: calc(100vw - 44px);
}
.brand p {
max-width: 250px;
margin-top: 9px;
font-size: 11px;
}
.help-panel {
display: none;
}
.bottom-bar {
right: 16px;
bottom: 17px;
left: 16px;
display: flex;
flex-direction: column;
gap: 11px;
}
.scene-stats {
display: none;
}
.actions {
justify-self: auto;
}
.desktop-hint {
display: none;
}
.mobile-hint {
display: inline;
}
}
@media (max-width: 420px) {
.eyebrow {
font-size: 8px;
}
.brand p {
max-width: 220px;
}
button {
padding: 8px 11px;
font-size: 9px;
}
.orbit-hint {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.live-dot {
animation: none;
}
}
</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="scene" aria-label="Interactive low-poly campfire in a forest clearing at night"></div>
<div class="vignette"></div>
<div class="ui">
<header class="brand">
<div class="eyebrow"><span class="live-dot"></span> AN INTERACTIVE LOW-POLY ESCAPE</div>
<h1>Emberwood</h1>
<p>A small fire. A wide forest. Nothing on the agenda.</p>
</header>
<aside class="help-panel">
<div class="help-title">EXPLORE THE CLEARING</div>
<div class="help-row"><span>Orbit the fire</span><kbd>DRAG</kbd></div>
<div class="help-row"><span>Move closer</span><kbd>SCROLL</kbd></div>
<div class="help-row"><span>Pause / reset</span><kbd>SPACE / R</kbd></div>
</aside>
<footer class="bottom-bar">
<div class="scene-stats">
<span id="tree-count">64 PINES</span>
<span class="separator"></span>
<span>NIGHTFALL</span>
</div>
<div class="orbit-hint">
<span class="desktop-hint">DRAG TO ORBIT · SCROLL TO ZOOM</span>
<span class="mobile-hint">DRAG TO ORBIT · PINCH TO ZOOM</span>
</div>
<div class="actions">
<button id="pause-button" type="button" aria-pressed="false">Pause motion</button>
<button id="reset-button" type="button">Reset view</button>
</div>
</footer>
</div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// Deterministic placement keeps the clearing composition consistent.
let randomState = 0x6d2b79f5;
function random() {
randomState = (Math.imul(randomState, 1664525) + 1013904223) >>> 0;
return randomState / 4294967296;
}
function range(min, max) {
return min + (max - min) * random();
}
function angleDelta(a, b) {
return Math.atan2(Math.sin(a - b), Math.cos(a - b));
}
const container = document.getElementById('scene');
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x04070d);
scene.fog = new THREE.Fog(0x09141c, 24, 64);
const camera = new THREE.PerspectiveCamera(
50,
window.innerWidth / window.innerHeight,
0.1,
220
);
const renderer = new THREE.WebGLRenderer({
antialias: true,
powerPreference: 'high-performance'
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.12;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.domElement.tabIndex = 0;
renderer.domElement.setAttribute(
'aria-label',
'3D forest scene. Drag to orbit and scroll or pinch to zoom.'
);
container.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.055;
controls.enablePan = false;
controls.minDistance = 6.5;
controls.maxDistance = 34;
controls.minPolarAngle = 0.18;
controls.maxPolarAngle = Math.PI / 2 - 0.08;
controls.rotateSpeed = 0.48;
controls.zoomSpeed = 0.72;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.22;
let paused = false;
function setInitialView() {
const mobile = window.innerWidth < 640;
if (mobile) {
camera.position.set(18, 11, 12);
camera.fov = 56;
controls.target.set(0, 2.0, 0);
} else {
camera.position.set(12, 8.5, 16);
camera.fov = 50;
controls.target.set(0, 1.6, 0);
}
camera.updateProjectionMatrix();
controls.update();
}
setInitialView();
controls.addEventListener('start', () => {
controls.autoRotate = false;
});
// Lighting: cool moonlight balanced by the warm, animated fire.
const ambientLight = new THREE.AmbientLight(0x31435d, 0.58);
scene.add(ambientLight);
const hemisphereLight = new THREE.HemisphereLight(0x6b83aa, 0x17160f, 0.82);
scene.add(hemisphereLight);
const moonLight = new THREE.DirectionalLight(0xa9c4ed, 1.05);
moonLight.position.set(-25, 35, -30);
moonLight.castShadow = true;
moonLight.shadow.mapSize.set(1024, 1024);
moonLight.shadow.camera.left = -32;
moonLight.shadow.camera.right = 32;
moonLight.shadow.camera.top = 32;
moonLight.shadow.camera.bottom = -32;
moonLight.shadow.camera.near = 1;
moonLight.shadow.camera.far = 100;
moonLight.shadow.bias = -0.0006;
moonLight.shadow.normalBias = 0.05;
scene.add(moonLight);
scene.add(moonLight.target);
const fireLight = new THREE.PointLight(0xff7a2c, 105, 27, 2);
fireLight.position.set(0, 2.1, 0);
fireLight.castShadow = true;
fireLight.shadow.mapSize.set(512, 512);
fireLight.shadow.camera.near = 0.18;
fireLight.shadow.camera.far = 27;
fireLight.shadow.camera.updateProjectionMatrix();
fireLight.shadow.bias = -0.0018;
fireLight.shadow.normalBias = 0.045;
fireLight.shadow.radius = 3;
scene.add(fireLight);
const emberLight = new THREE.PointLight(0xffb058, 12, 6, 2);
emberLight.position.set(0, 0.75, 0);
scene.add(emberLight);
// Gradient sky dome.
const skyGeometry = new THREE.SphereGeometry(115, 32, 18);
const skyMaterial = new THREE.ShaderMaterial({
uniforms: {
zenithColor: { value: new THREE.Color(0x02040b) },
horizonColor: { value: new THREE.Color(0x17293b) }
},
vertexShader: `
varying vec3 vWorldPosition;
void main() {
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
vWorldPosition = worldPosition.xyz;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 zenithColor;
uniform vec3 horizonColor;
varying vec3 vWorldPosition;
void main() {
float h = normalize(vWorldPosition).y;
float blend = smoothstep(-0.08, 0.72, h);
vec3 color = mix(horizonColor, zenithColor, blend);
gl_FragColor = vec4(color, 1.0);
#include <colorspace_fragment>
}
`,
side: THREE.BackSide,
depthWrite: false,
fog: false,
toneMapped: false
});
const sky = new THREE.Mesh(skyGeometry, skyMaterial);
sky.renderOrder = -100;
sky.frustumCulled = false;
scene.add(sky);
function makeStars(count, size, opacity, minimumY) {
const positions = new Float32Array(count * 3);
const colors = new Float32Array(count * 3);
const starColor = new THREE.Color();
for (let i = 0; i < count; i++) {
const y = range(minimumY, 0.97);
const azimuth = random() * Math.PI * 2;
const horizontal = Math.sqrt(1 - y * y);
const radius = range(94, 103);
const p = i * 3;
positions[p] = Math.cos(azimuth) * horizontal * radius;
positions[p + 1] = y * radius;
positions[p + 2] = Math.sin(azimuth) * horizontal * radius;
if (random() > 0.83) {
starColor.setHSL(range(0.08, 0.14), range(0.15, 0.35), range(0.72, 0.95));
} else {
starColor.setHSL(range(0.53, 0.65), range(0.08, 0.32), range(0.68, 0.92));
}
colors[p] = starColor.r;
colors[p + 1] = starColor.g;
colors[p + 2] = starColor.b;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
const material = new THREE.PointsMaterial({
size,
sizeAttenuation: true,
vertexColors: true,
transparent: true,
opacity,
depthWrite: false,
blending: THREE.AdditiveBlending,
fog: false,
toneMapped: false
});
const points = new THREE.Points(geometry, material);
points.renderOrder = -20;
scene.add(points);
return points;
}
const stars = makeStars(820, 0.31, 0.84, 0.055);
const brightStars = makeStars(58, 0.62, 0.88, 0.16);
function makeRadialTexture(stops) {
const canvas = document.createElement('canvas');
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext('2d');
const gradient = context.createRadialGradient(64, 64, 0, 64, 64, 64);
for (const [offset, color] of stops) {
gradient.addColorStop(offset, color);
}
context.fillStyle = gradient;
context.fillRect(0, 0, 128, 128);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
return texture;
}
const moonPosition = new THREE.Vector3(-58, 17, -64);
const moon = new THREE.Mesh(
new THREE.SphereGeometry(2.45, 20, 12),
new THREE.MeshBasicMaterial({
color: 0xfff1c9,
fog: false,
toneMapped: false,
flatShading: true
})
);
moon.position.copy(moonPosition);
scene.add(moon);
const moonHaloTexture = makeRadialTexture([
[0, 'rgba(255,255,255,0.38)'],
[0.17, 'rgba(255,255,255,0.20)'],
[0.45, 'rgba(255,255,255,0.065)'],
[1, 'rgba(255,255,255,0)']
]);
const moonHalo = new THREE.Sprite(
new THREE.SpriteMaterial({
map: moonHaloTexture,
color: 0xb7d2ff,
transparent: true,
opacity: 0.55,
depthWrite: false,
blending: THREE.AdditiveBlending,
fog: false,
toneMapped: false
})
);
moonHalo.position.copy(moonPosition);
moonHalo.scale.set(18, 18, 1);
scene.add(moonHalo);
// Faceted rolling forest floor.
function groundHeight(x, z) {
const radius = Math.hypot(x, z);
const blend = THREE.MathUtils.smoothstep(radius, 6, 13);
const rolling =
Math.sin(x * 0.31) * 0.24 +
Math.cos(z * 0.27) * 0.20 +
Math.sin((x + z) * 0.17) * 0.16;
return -0.12 + blend * rolling;
}
const groundGeometry = new THREE.PlaneGeometry(100, 100, 40, 40);
groundGeometry.rotateX(-Math.PI / 2);
const groundPosition = groundGeometry.attributes.position;
const groundColors = new Float32Array(groundPosition.count * 3);
const groundColorA = new THREE.Color(0x14251f);
const groundColorB = new THREE.Color(0x24372a);
const workingColor = new THREE.Color();
for (let i = 0; i < groundPosition.count; i++) {
const x = groundPosition.getX(i);
const z = groundPosition.getZ(i);
groundPosition.setY(i, groundHeight(x, z));
const variation = THREE.MathUtils.clamp(
0.48 + Math.sin(x * 0.39 + z * 0.17) * 0.24 + Math.cos(z * 0.31) * 0.15,
0,
1
);
workingColor.copy(groundColorA).lerp(groundColorB, variation);
groundColors[i * 3] = workingColor.r;
groundColors[i * 3 + 1] = workingColor.g;
groundColors[i * 3 + 2] = workingColor.b;
}
groundGeometry.setAttribute('color', new THREE.BufferAttribute(groundColors, 3));
groundGeometry.computeVertexNormals();
const ground = new THREE.Mesh(
groundGeometry,
new THREE.MeshStandardMaterial({
vertexColors: true,
roughness: 1,
metalness: 0,
flatShading: true
})
);
ground.receiveShadow = true;
scene.add(ground);
// Irregular earthen clearing, built as individually colored triangles.
const clearingPositions = [];
const clearingColors = [];
const clearingSegments = 40;
const clearingRing = [];
const clearingColorA = new THREE.Color(0x30271f);
const clearingColorB = new THREE.Color(0x47382a);
for (let i = 0; i < clearingSegments; i++) {
const angle = (i / clearingSegments) * Math.PI * 2;
const radius = 7.65 + Math.sin(angle * 5) * 0.28 + range(-0.25, 0.25);
clearingRing.push(new THREE.Vector3(
Math.cos(angle) * radius,
range(-0.025, 0.025),
Math.sin(angle) * radius
));
}
for (let i = 0; i < clearingSegments; i++) {
const current = clearingRing[i];
const next = clearingRing[(i + 1) % clearingSegments];
clearingPositions.push(
0, 0, 0,
next.x, next.y, next.z,
current.x, current.y, current.z
);
workingColor.copy(clearingColorA).lerp(clearingColorB, random());
for (let j = 0; j < 3; j++) {
clearingColors.push(workingColor.r, workingColor.g, workingColor.b);
}
}
const clearingGeometry = new THREE.BufferGeometry();
clearingGeometry.setAttribute(
'position',
new THREE.Float32BufferAttribute(clearingPositions, 3)
);
clearingGeometry.setAttribute(
'color',
new THREE.Float32BufferAttribute(clearingColors, 3)
);
clearingGeometry.computeVertexNormals();
const clearing = new THREE.Mesh(
clearingGeometry,
new THREE.MeshStandardMaterial({
vertexColors: true,
roughness: 1,
flatShading: true
})
);
clearing.position.y = -0.052;
clearing.receiveShadow = true;
scene.add(clearing);
// Sixty-four instanced, layered low-poly pines around the open clearing.
const TREE_COUNT = 64;
const treeData = [];
const desktopViewAngle = Math.atan2(16, 12);
const mobileViewAngle = Math.atan2(12, 18);
const moonViewAngle = Math.atan2(moonPosition.z, moonPosition.x);
let treeAttempts = 0;
while (treeData.length < TREE_COUNT && treeAttempts < 4000) {
treeAttempts++;
const radius = 10 + Math.pow(random(), 0.72) * 28;
const angle = random() * Math.PI * 2;
const x = Math.cos(angle) * radius;
const z = Math.sin(angle) * radius;
const blocksOpeningView =
radius < 20 &&
(
Math.abs(angleDelta(angle, desktopViewAngle)) < 0.17 ||
Math.abs(angleDelta(angle, mobileViewAngle)) < 0.15
);
const blocksMoon =
radius < 31 && Math.abs(angleDelta(angle, moonViewAngle)) < 0.075;
if (blocksOpeningView || blocksMoon) continue;
let overlaps = false;
for (const tree of treeData) {
if (Math.hypot(tree.x - x, tree.z - z) < 1.7) {
overlaps = true;
break;
}
}
if (overlaps) continue;
treeData.push({
x,
z,
y: groundHeight(x, z),
height: range(6.2, 11.4),
rotation: random() * Math.PI * 2,
palette: Math.floor(random() * 4)
});
}
document.getElementById('tree-count').textContent = `${treeData.length} PINES`;
const trunkGeometry = new THREE.CylinderGeometry(0.76, 1, 1, 6, 1, false);
const crownGeometry = new THREE.ConeGeometry(1, 1, 7, 1, false);
const trunkMaterial = new THREE.MeshStandardMaterial({
color: 0xffffff,
roughness: 1,
flatShading: true
});
const crownMaterial = new THREE.MeshStandardMaterial({
color: 0xffffff,
roughness: 0.96,
flatShading: true
});
const trunks = new THREE.InstancedMesh(trunkGeometry, trunkMaterial, treeData.length);
const lowerCrowns = new THREE.InstancedMesh(crownGeometry, crownMaterial, treeData.length);
const middleCrowns = new THREE.InstancedMesh(crownGeometry, crownMaterial, treeData.length);
const upperCrowns = new THREE.InstancedMesh(crownGeometry, crownMaterial, treeData.length);
const pinePalette = [0x17372f, 0x1c4034, 0x234a39, 0x2a503e];
const trunkPalette = [0x30241e, 0x39291f, 0x403025];
const instanceMatrix = new THREE.Matrix4();
const instancePosition = new THREE.Vector3();
const instanceScale = new THREE.Vector3();
const instanceQuaternion = new THREE.Quaternion();
const instanceEuler = new THREE.Euler();
function setTreeInstance(mesh, index, x, y, z, sx, sy, sz, rotation) {
instancePosition.set(x, y, z);
instanceScale.set(sx, sy, sz);
instanceEuler.set(0, rotation, 0);
instanceQuaternion.setFromEuler(instanceEuler);
instanceMatrix.compose(instancePosition, instanceQuaternion, instanceScale);
mesh.setMatrixAt(index, instanceMatrix);
}
treeData.forEach((tree, index) => {
const height = tree.height;
const trunkHeight = height * 0.32;
const trunkRadius = height * 0.035;
const lowerHeight = height * 0.48;
const middleHeight = height * 0.42;
const upperHeight = height * 0.32;
const crownBase = trunkHeight * 0.45;
setTreeInstance(
trunks, index,
tree.x, tree.y + trunkHeight / 2, tree.z,
trunkRadius, trunkHeight, trunkRadius,
tree.rotation
);
setTreeInstance(
lowerCrowns, index,
tree.x, tree.y + crownBase + lowerHeight / 2, tree.z,
height * 0.215, lowerHeight, height * 0.215,
tree.rotation
);
setTreeInstance(
middleCrowns, index,
tree.x + Math.sin(tree.rotation) * 0.08,
tree.y + crownBase + height * 0.27 + middleHeight / 2,
tree.z + Math.cos(tree.rotation) * 0.08,
height * 0.165, middleHeight, height * 0.165,
tree.rotation + 0.35
);
setTreeInstance(
upperCrowns, index,
tree.x, tree.y + crownBase + height * 0.52 + upperHeight / 2, tree.z,
height * 0.11, upperHeight, height * 0.11,
tree.rotation + 0.7
);
workingColor.set(trunkPalette[index % trunkPalette.length]);
trunks.setColorAt(index, workingColor);
workingColor.set(pinePalette[tree.palette]).multiplyScalar(0.88);
lowerCrowns.setColorAt(index, workingColor);
workingColor.set(pinePalette[tree.palette]);
middleCrowns.setColorAt(index, workingColor);
workingColor.set(pinePalette[tree.palette]).multiplyScalar(1.12);
upperCrowns.setColorAt(index, workingColor);
});
for (const mesh of [trunks, lowerCrowns, middleCrowns, upperCrowns]) {
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.instanceMatrix.needsUpdate = true;
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
scene.add(mesh);
}
// Small triangular tufts soften the edge of the clearing.
const GRASS_COUNT = 145;
const grassGeometry = new THREE.ConeGeometry(1, 1, 3, 1);
const grassMaterial = new THREE.MeshStandardMaterial({
color: 0xffffff,
roughness: 1,
flatShading: true
});
const grass = new THREE.InstancedMesh(grassGeometry, grassMaterial, GRASS_COUNT);
for (let i = 0; i < GRASS_COUNT; i++) {
const angle = random() * Math.PI * 2;
const radius = range(7.9, 29);
const x = Math.cos(angle) * radius;
const z = Math.sin(angle) * radius;
const height = range(0.3, 0.82);
instancePosition.set(x, groundHeight(x, z) + height * 0.42, z);
instanceScale.set(range(0.09, 0.21), height, range(0.09, 0.21));
instanceEuler.set(range(-0.12, 0.12), random() * Math.PI * 2, range(-0.12, 0.12));
instanceQuaternion.setFromEuler(instanceEuler);
instanceMatrix.compose(instancePosition, instanceQuaternion, instanceScale);
grass.setMatrixAt(i, instanceMatrix);
workingColor.set(i % 3 === 0 ? 0x294331 : 0x20382b);
grass.setColorAt(i, workingColor);
}
grass.receiveShadow = true;
grass.instanceMatrix.needsUpdate = true;
if (grass.instanceColor) grass.instanceColor.needsUpdate = true;
scene.add(grass);
// Logs and stumps double as rustic seats around the fire.
const seatBarkMaterial = new THREE.MeshStandardMaterial({
color: 0x3b2a20,
roughness: 1,
flatShading: true
});
const seatEndMaterial = new THREE.MeshStandardMaterial({
color: 0x896443,
roughness: 0.95,
flatShading: true
});
const charredBarkMaterial = new THREE.MeshStandardMaterial({
color: 0x25150f,
roughness: 1,
flatShading: true
});
const charredEndMaterial = new THREE.MeshStandardMaterial({
color: 0x4d2b19,
roughness: 1,
flatShading: true
});
function makeLog(length, radius, x, y, z, angle, charred = false) {
const geometry = new THREE.CylinderGeometry(
radius * 0.88,
radius,
length,
8,
1,
false
);
const materials = charred
? [charredBarkMaterial, charredEndMaterial, charredEndMaterial]
: [seatBarkMaterial, seatEndMaterial, seatEndMaterial];
const log = new THREE.Mesh(geometry, materials);
log.position.set(x, y, z);
log.rotation.y = angle;
log.rotation.z = Math.PI / 2;
log.castShadow = true;
log.receiveShadow = true;
scene.add(log);
return log;
}
const seatLocations = [
{ x: -4.6, z: 2.8 },
{ x: 4.4, z: -3.0 },
{ x: -3.4, z: -4.5 },
{ x: 4.9, z: 3.6 },
{ x: -6.0, z: -0.5 }
];
makeLog(4.1, 0.46, -4.6, groundHeight(-4.6, 2.8) + 0.42, 2.8, -0.35);
makeLog(3.6, 0.41, 4.4, groundHeight(4.4, -3.0) + 0.37, -3.0, 1.08);
makeLog(3.3, 0.39, -3.4, groundHeight(-3.4, -4.5) + 0.35, -4.5, 0.14);
function makeStump(x, z, radius, height) {
const stump = new THREE.Mesh(
new THREE.CylinderGeometry(radius * 0.88, radius, height, 8, 1, false),
[seatBarkMaterial, seatEndMaterial, seatEndMaterial]
);
stump.position.set(x, groundHeight(x, z) + height / 2, z);
stump.rotation.y = random() * Math.PI;
stump.castShadow = true;
stump.receiveShadow = true;
scene.add(stump);
}
makeStump(4.9, 3.6, 0.62, 0.8);
makeStump(-6.0, -0.5, 0.48, 0.63);
// Crossed firewood at the center.
makeLog(2.55, 0.19, 0, 0.20, 0, 0.05, true);
makeLog(2.45, 0.18, 0, 0.40, 0, Math.PI / 3, true);
makeLog(2.35, 0.17, 0, 0.58, 0, -Math.PI / 3, true);
// Faceted stones form the fire ring and scatter around the clearing.
const rockGeometry = new THREE.DodecahedronGeometry(1, 0);
const rockMaterials = [
new THREE.MeshStandardMaterial({ color: 0x5b5a52, roughness: 1, flatShading: true }),
new THREE.MeshStandardMaterial({ color: 0x6a6255, roughness: 1, flatShading: true }),
new THREE.MeshStandardMaterial({ color: 0x484d49, roughness: 1, flatShading: true }),
new THREE.MeshStandardMaterial({ color: 0x746a59, roughness: 1, flatShading: true })
];
function addRock(x, z, size, forcedY = null) {
const rock = new THREE.Mesh(
rockGeometry,
rockMaterials[Math.floor(random() * rockMaterials.length)]
);
const scaleY = size * range(0.52, 0.78);
rock.scale.set(size * range(0.82, 1.18), scaleY, size * range(0.82, 1.18));
rock.position.set(
x,
forcedY === null ? groundHeight(x, z) + scaleY * 0.72 : forcedY,
z
);
rock.rotation.set(random() * 0.5, random() * Math.PI * 2, random() * 0.5);
rock.castShadow = true;
rock.receiveShadow = true;
scene.add(rock);
}
for (let i = 0; i < 12; i++) {
const angle = (i / 12) * Math.PI * 2 + range(-0.045, 0.045);
const radius = range(1.39, 1.53);
addRock(
Math.cos(angle) * radius,
Math.sin(angle) * radius,
range(0.39, 0.52),
range(0.16, 0.22)
);
}
let scatteredRocks = 0;
let rockAttempts = 0;
while (scatteredRocks < 18 && rockAttempts < 300) {
rockAttempts++;
const angle = random() * Math.PI * 2;
const radius = range(3.7, 11);
const x = Math.cos(angle) * radius;
const z = Math.sin(angle) * radius;
let nearSeat = false;
for (const seat of seatLocations) {
if (Math.hypot(x - seat.x, z - seat.z) < 1.55) {
nearSeat = true;
break;
}
}
if (nearSeat) continue;
addRock(x, z, range(0.24, 0.72));
scatteredRocks++;
}
// Glowing coals beneath the flames.
const emberMaterials = [
new THREE.MeshStandardMaterial({
color: 0x49170b,
emissive: 0xff3d09,
emissiveIntensity: 1.8,
roughness: 1,
flatShading: true
}),
new THREE.MeshStandardMaterial({
color: 0x5e230e,
emissive: 0xff8a22,
emissiveIntensity: 1.5,
roughness: 1,
flatShading: true
})
];
const emberGeometry = new THREE.IcosahedronGeometry(0.12, 0);
for (let i = 0; i < 22; i++) {
const angle = random() * Math.PI * 2;
const radius = random() * 0.86;
const ember = new THREE.Mesh(emberGeometry, emberMaterials[i % 2]);
ember.position.set(Math.cos(angle) * radius, range(0.16, 0.28), Math.sin(angle) * radius);
ember.scale.set(range(0.6, 1.5), range(0.35, 0.8), range(0.6, 1.5));
ember.rotation.set(random() * Math.PI, random() * Math.PI, random() * Math.PI);
scene.add(ember);
}
// A soft procedural glow gives the flames bloom without post-processing.
const fireGlowTexture = makeRadialTexture([
[0, 'rgba(255,255,255,0.82)'],
[0.18, 'rgba(255,255,255,0.42)'],
[0.48, 'rgba(255,255,255,0.11)'],
[1, 'rgba(255,255,255,0)']
]);
const fireGlow = new THREE.Sprite(
new THREE.SpriteMaterial({
map: fireGlowTexture,
color: 0xff6d20,
transparent: true,
opacity: 0.25,
depthWrite: false,
blending: THREE.AdditiveBlending,
fog: false,
toneMapped: false
})
);
fireGlow.position.set(0, 1.35, 0);
fireGlow.scale.set(5.1, 5.1, 1);
fireGlow.renderOrder = 1;
scene.add(fireGlow);
// Faceted tapered tongues with per-vertex fire colors.
function createFlameGeometry(radius, height, bend) {
const geometry = new THREE.ConeGeometry(radius, height, 7, 3, false);
geometry.translate(0, height / 2, 0);
const position = geometry.attributes.position;
const colors = new Float32Array(position.count * 3);
const baseColor = new THREE.Color(0xffd369);
const middleColor = new THREE.Color(0xff781c);
const tipColor = new THREE.Color(0xe63209);
const color = new THREE.Color();
for (let i = 0; i < position.count; i++) {
const y = position.getY(i);
const t = THREE.MathUtils.clamp(y / height, 0, 1);
position.setX(i, position.getX(i) + bend * t * t);
position.setZ(i, position.getZ(i) + Math.sin(t * Math.PI) * bend * 0.24);
if (t < 0.55) {
color.copy(baseColor).lerp(middleColor, t / 0.55);
} else {
color.copy(middleColor).lerp(tipColor, (t - 0.55) / 0.45);
}
colors[i * 3] = color.r;
colors[i * 3 + 1] = color.g;
colors[i * 3 + 2] = color.b;
}
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geometry.computeVertexNormals();
return geometry;
}
const flameGroup = new THREE.Group();
scene.add(flameGroup);
const flames = [];
const flameSpecs = [
{ radius: 0.65, height: 2.45, x: 0.00, z: 0.00, bend: 0.22, phase: 0.0, opacity: 0.58 },
{ radius: 0.43, height: 1.92, x: 0.39, z: 0.10, bend: -0.19, phase: 1.3, opacity: 0.54 },
{ radius: 0.40, height: 2.06, x: -0.35, z: 0.12, bend: 0.18, phase: 2.6, opacity: 0.52 },
{ radius: 0.35, height: 1.63, x: 0.10, z: -0.36, bend: -0.14, phase: 4.2, opacity: 0.56 },
{ radius: 0.31, height: 1.79, x: -0.12, z: 0.38, bend: 0.16, phase: 5.1, opacity: 0.52 },
{ radius: 0.35, height: 1.48, x: 0.00, z: 0.02, bend: 0.09, phase: 0.8, opacity: 0.91, inner: true, color: 0xffe393 },
{ radius: 0.20, height: 1.03, x: 0.23, z: 0.13, bend: -0.08, phase: 3.3, opacity: 0.94, inner: true, color: 0xfff2bd }
];
flameSpecs.forEach((spec, index) => {
const geometry = createFlameGeometry(spec.radius, spec.height, spec.bend);
const material = new THREE.MeshBasicMaterial({
color: spec.inner ? spec.color : 0xffffff,
vertexColors: !spec.inner,
transparent: true,
opacity: spec.opacity,
side: THREE.DoubleSide,
depthWrite: false,
blending: spec.inner ? THREE.NormalBlending : THREE.AdditiveBlending,
fog: false,
toneMapped: false
});
const flame = new THREE.Mesh(geometry, material);
flame.position.set(spec.x, 0.45, spec.z);
flame.rotation.y = spec.phase;
flame.renderOrder = spec.inner ? 5 : 4;
flame.userData = {
baseX: spec.x,
baseZ: spec.z,
phase: spec.phase,
baseOpacity: spec.opacity,
rotation: spec.phase,
speed: range(2.3, 3.2),
index
};
flameGroup.add(flame);
flames.push(flame);
});
// Wispy procedural smoke sprites.
const smokeTexture = makeRadialTexture([
[0, 'rgba(255,255,255,0.38)'],
[0.35, 'rgba(255,255,255,0.20)'],
[0.72, 'rgba(255,255,255,0.055)'],
[1, 'rgba(255,255,255,0)']
]);
const smokePuffs = [];
for (let i = 0; i < 8; i++) {
const material = new THREE.SpriteMaterial({
map: smokeTexture,
color: 0x78828a,
transparent: true,
opacity: 0.05,
depthWrite: false,
rotation: random() * Math.PI,
fog: true
});
const puff = new THREE.Sprite(material);
puff.userData.offset = i / 8;
puff.userData.drift = range(-0.45, 0.45);
puff.renderOrder = 2;
scene.add(puff);
smokePuffs.push(puff);
}
// Rising spark particle system with per-particle lifetime and alpha.
const SPARK_COUNT = 120;
const sparkPositions = new Float32Array(SPARK_COUNT * 3);
const sparkColors = new Float32Array(SPARK_COUNT * 3);
const sparkSizes = new Float32Array(SPARK_COUNT);
const sparkAlphas = new Float32Array(SPARK_COUNT);
const sparkData = Array.from({ length: SPARK_COUNT }, () => ({
vx: 0,
vy: 0,
vz: 0,
age: 0,
life: 1,
seed: 0
}));
const sparkGeometry = new THREE.BufferGeometry();
sparkGeometry.setAttribute(
'position',
new THREE.BufferAttribute(sparkPositions, 3).setUsage(THREE.DynamicDrawUsage)
);
sparkGeometry.setAttribute(
'aColor',
new THREE.BufferAttribute(sparkColors, 3).setUsage(THREE.DynamicDrawUsage)
);
sparkGeometry.setAttribute(
'aSize',
new THREE.BufferAttribute(sparkSizes, 1).setUsage(THREE.DynamicDrawUsage)
);
sparkGeometry.setAttribute(
'aAlpha',
new THREE.BufferAttribute(sparkAlphas, 1).setUsage(THREE.DynamicDrawUsage)
);
const sparkMaterial = new THREE.ShaderMaterial({
uniforms: {
uPixelRatio: { value: renderer.getPixelRatio() }
},
vertexShader: `
attribute vec3 aColor;
attribute float aSize;
attribute float aAlpha;
uniform float uPixelRatio;
varying vec3 vColor;
varying float vAlpha;
void main() {
vColor = aColor;
vAlpha = aAlpha;
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
float perspectiveSize = aSize * uPixelRatio * (340.0 / max(1.0, -mvPosition.z));
gl_PointSize = clamp(perspectiveSize, 1.0, 10.0);
gl_Position = projectionMatrix * mvPosition;
}
`,
fragmentShader: `
varying vec3 vColor;
varying float vAlpha;
void main() {
float distanceToCenter = distance(gl_PointCoord, vec2(0.5));
float alpha = (1.0 - smoothstep(0.08, 0.5, distanceToCenter)) * vAlpha;
if (alpha < 0.015) discard;
gl_FragColor = vec4(vColor, alpha);
#include <colorspace_fragment>
}
`,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
toneMapped: false
});
const sparks = new THREE.Points(sparkGeometry, sparkMaterial);
sparks.frustumCulled = false;
sparks.renderOrder = 6;
scene.add(sparks);
function resetSpark(index, initial = false) {
const data = sparkData[index];
const p = index * 3;
const angle = random() * Math.PI * 2;
const radius = random() * 0.54;
data.vx = range(-0.24, 0.24);
data.vy = range(1.25, 3.45);
data.vz = range(-0.24, 0.24);
data.life = range(1.15, 3.35);
data.age = initial ? random() * data.life : 0;
data.seed = random() * Math.PI * 2;
sparkPositions[p] =
Math.cos(angle) * radius + data.vx * data.age;
sparkPositions[p + 1] =
0.58 + data.vy * data.age - 0.11 * data.age * data.age;
sparkPositions[p + 2] =
Math.sin(angle) * radius + data.vz * data.age;
sparkSizes[index] = range(0.09, 0.21);
const lifeRatio = data.age / data.life;
sparkAlphas[index] =
Math.min(1, lifeRatio * 10) * Math.pow(Math.max(0, 1 - lifeRatio), 0.7);
workingColor.set(random() > 0.42 ? 0xffd783 : 0xff7625);
sparkColors[p] = workingColor.r;
sparkColors[p + 1] = workingColor.g;
sparkColors[p + 2] = workingColor.b;
}
for (let i = 0; i < SPARK_COUNT; i++) {
resetSpark(i, true);
}
function updateSparks(delta, elapsed) {
for (let i = 0; i < SPARK_COUNT; i++) {
const data = sparkData[i];
data.age += delta;
if (data.age >= data.life) {
resetSpark(i);
continue;
}
const p = i * 3;
sparkPositions[p] +=
(data.vx + Math.sin(elapsed * 1.7 + data.seed) * 0.12) * delta;
sparkPositions[p + 1] +=
(data.vy - 0.22 * data.age) * delta;
sparkPositions[p + 2] +=
(data.vz + Math.cos(elapsed * 1.45 + data.seed) * 0.11) * delta;
const lifeRatio = data.age / data.life;
sparkAlphas[i] =
Math.min(1, lifeRatio * 10) * Math.pow(1 - lifeRatio, 0.7);
}
sparkGeometry.attributes.position.needsUpdate = true;
sparkGeometry.attributes.aColor.needsUpdate = true;
sparkGeometry.attributes.aSize.needsUpdate = true;
sparkGeometry.attributes.aAlpha.needsUpdate = true;
}
function updateFire(elapsed) {
flames.forEach((flame) => {
const data = flame.userData;
const t = elapsed * data.speed + data.phase;
const width = 1 + Math.sin(t * 1.27) * 0.10 + Math.cos(t * 0.63) * 0.045;
const height =
0.91 + Math.sin(t) * 0.15 + Math.sin(t * 2.31 + data.index) * 0.065;
flame.scale.set(width, height, 1 + Math.cos(t * 1.12) * 0.09);
flame.position.x = data.baseX + Math.sin(t * 0.72) * 0.075;
flame.position.z = data.baseZ + Math.cos(t * 0.64) * 0.06;
flame.position.y = 0.45 + Math.sin(t * 1.4) * 0.025;
flame.rotation.y = data.rotation + Math.sin(t * 0.43) * 0.20;
flame.rotation.z = Math.sin(t * 0.81) * 0.065;
flame.material.opacity =
data.baseOpacity * (0.9 + Math.sin(t * 1.83) * 0.08);
});
const flicker =
Math.sin(elapsed * 13.1) * 10 +
Math.sin(elapsed * 23.7 + 1.4) * 6 +
Math.sin(elapsed * 37.3 + 0.2) * 3.5;
fireLight.intensity = 105 + flicker;
fireLight.position.x = Math.sin(elapsed * 9.1) * 0.075;
fireLight.position.y = 2.08 + Math.sin(elapsed * 11.7) * 0.09;
fireLight.position.z = Math.cos(elapsed * 8.3) * 0.07;
emberLight.intensity = 12 + Math.sin(elapsed * 8.4) * 2.2;
const glowScale = 5.05 + Math.sin(elapsed * 8.7) * 0.22;
fireGlow.scale.set(glowScale, glowScale, 1);
fireGlow.material.opacity = 0.245 + Math.sin(elapsed * 12.2) * 0.025;
emberMaterials[0].emissiveIntensity = 1.75 + Math.sin(elapsed * 7.4) * 0.35;
emberMaterials[1].emissiveIntensity = 1.55 + Math.sin(elapsed * 9.1 + 2) * 0.3;
}
function updateSmoke(elapsed) {
smokePuffs.forEach((puff, index) => {
const progress = (elapsed * 0.105 + puff.userData.offset) % 1;
const drift = puff.userData.drift;
const scale = 0.75 + progress * 2.8;
puff.position.set(
Math.sin(elapsed * 0.42 + index) * progress * 0.52 + drift * progress,
1.9 + progress * 5.2,
Math.cos(elapsed * 0.35 + index * 0.7) * progress * 0.34
);
puff.scale.set(scale, scale, 1);
puff.material.opacity = Math.sin(progress * Math.PI) * 0.075;
puff.material.rotation += 0.0007 * (index % 2 === 0 ? 1 : -1);
});
}
const pauseButton = document.getElementById('pause-button');
const resetButton = document.getElementById('reset-button');
function togglePause() {
paused = !paused;
pauseButton.textContent = paused ? 'Resume motion' : 'Pause motion';
pauseButton.setAttribute('aria-pressed', String(paused));
controls.autoRotate = !paused;
}
function resetView() {
setInitialView();
controls.autoRotate = !paused;
}
pauseButton.addEventListener('click', togglePause);
resetButton.addEventListener('click', resetView);
window.addEventListener('keydown', (event) => {
if (event.target instanceof HTMLButtonElement) return;
if (event.code === 'Space') {
event.preventDefault();
togglePause();
} else if (event.code === 'KeyR') {
resetView();
}
});
function onResize() {
const width = window.innerWidth;
const height = window.innerHeight;
const pixelRatioCap = width < 640 ? 1.5 : 2;
camera.aspect = width / height;
camera.fov = width < 640 ? 56 : 50;
camera.updateProjectionMatrix();
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, pixelRatioCap));
renderer.setSize(width, height);
sparkMaterial.uniforms.uPixelRatio.value = renderer.getPixelRatio();
}
window.addEventListener('resize', onResize, { passive: true });
const clock = new THREE.Clock();
let elapsed = 0;
function animate() {
requestAnimationFrame(animate);
const delta = Math.min(clock.getDelta(), 0.04);
if (!paused) {
elapsed += delta;
updateFire(elapsed);
updateSparks(delta, elapsed);
updateSmoke(elapsed);
stars.material.opacity = 0.82 + Math.sin(elapsed * 0.55) * 0.055;
brightStars.material.opacity = 0.85 + Math.sin(elapsed * 0.9 + 1.4) * 0.09;
}
controls.update();
renderer.render(scene, camera);
}
updateFire(0);
updateSmoke(0);
animate();
</script>
</body>
</html>Reasoning trace (26,004 tokens)
Kimi needs to output exactly one fenced code block containing a complete html file. Should build excellent Three.js low poly campfire forest night. Should comply imports exactly. Should meaningful first paint 3 sec, visible all interactions, self-playing demo mode? Task isn't game, so no game needed. Orbit controls look around, perhaps auto-rotate gentle initial demo and UI. Should have no external assets other than Three cdn explicitly. Inline CSS JS. Should have robust no errors latest Chrome. Should design aesthetically stunning. Should have 1280x800 adapt 375 no scrollbar. Should warm campfire flames animated point light flicker shadows. 30+ low-poly trees random around clearing. Ground rocks logs. Sky stars moon fog. Controls limits above ground. Resize.
Should plan visual and implementation details. Should use Three.js 0.170 import map and OrbitControls addon. Build scene physically appealing low-poly flat shading. Canvas full-screen with HUD elegant forest night title and small controls. Perhaps vignette CSS overlay, top header bottom badges. But main scene dominates.
I need ensure performance shadows. Three scene with fogExp2 or Fog linear. Camera initial 15,11,20 etc looking at (0,1,0). Should have 45+ trees in ring around radius 8-28, no fire. Flat-shaded cones/icosahedra trunk cylinders. Use deterministic random for consistent placement. Variation. Night sky stars via BufferGeometry Points with custom colors/sizes? Three PointsMaterial size. Moon via sphere positioned far and emissive material plus halo sprite generated via canvas? Inline no external images, canvas generated okay. Could create moon as sphere geometry placed at (-35,32,-55) and add halo ring maybe sprite. Lighting directional moonlight cast shadows? Requirement warm point light casts shadows. Set fire light castShadow true with shadow map 1024 and bias. Perhaps directional light subtle blue castShadow false or true at 1024. Point light 6 faces shadows heavy but okay limited objects. Use instanced trees? 40 objects x meshes many draw calls; fine. Use group per tree with trunk & layered cones. 48 trees * 4 =192 draw calls + shadows could be heavy but manageable. Could use merged? No BufferGeometryUtils unless addon allowed but no need. Instancing shadows easier but variation matrices. I can make 3 canopy geometries and trunks instanced? Should low-poly trees placed random around clearing. Instanced meshes with cast/receive. Use separate InstancedMesh trunk and foliage type per color or random colors. To be rich use groups ~42 with cylinder trunk and 2-3 cones. 50 trees x 4 200 drawcalls main plus 6 shadow passes =1400 perhaps moderately heavy. Point shadow only should affect objects within ~25 range; shadow camera far 40. Trees all cast, heavy. Could only nearest 20 cast shadows, all receive. Fire point light shadow 512; moon directional shadow optional no. Better performance.
Alternative instanced meshes for trunks and crown geometries across ~52 trees. Create arrays transformations and use THREE.InstancedMesh for trunk, lower, middle, upper foliage, all castShadow true perhaps single draw per mesh per shadow face = efficient. Can vary color per instance using setColorAt. Nice. Create e.g. 56 trees random ring radius 10-32 (some far fog). Tree positioning avoid central sight? I want clearing visible. InstancedMesh cylinder trunk radial 5, crown ConeGeometry radius etc 7 segments flatShading. Each tree 3 foliage layers with different scales and positions, can share transforms but matrices. Use matrix compose position scale rotation. Could create a group? Instance matrix includes rotation y and scale. Trunk geometry height centered; transform position y=height/2. Crown cone origin center; place at levels. Add slight tilt? not needed. Use seeded rand.
Ground: CircleGeometry radius perhaps 45 segments 64 rotated x -PI/2, material color dark desaturated green, roughness. Add irregular undulating ground? Low-poly CircleGeometry with segments and triangles? CircleGeometry radial triangles all center, flat plane. I can use PlaneGeometry(100,100,40,40), rotate, modify y heights outside central clearing, computeVertexNormals flatShading true. But plane triangle faceted and near center fire ground flat. Set y = small noise * smoothstep radius; positions plane local x,y before rotation. Easier create PlaneGeometry(90,90,36,36), rotateX(-PI/2), loop position x,z set y. Should have clearing center flat radius<7, outer low rolling ±0.6. compute normals; flatShading material; perhaps vertex colors varying per vertex for richer. PlaneGeometry indexed normals with flatShading does shader derivatives? flatShading true gives faceted. Use MeshStandardMaterial color #152a25, roughness 1, flatShading. Add vertex colors? create color per vertex based on noise and radial position; material vertexColors true base white. Add groundcircle clearing perhaps a separate flattened irregular CircleGeometry radius 8.5, material brown #302b23, positioned .015. Could create custom radial geometry with random radii and triangle fan, vertex colors earth, low poly. Outer ground visible. Add stone ring around fire 10-12 dodecahedron low-poly gray/brown stones. Fire logs 3 cylinders radius .22 length 2.8, oriented crossed on ground, bark dark brown; cylinder geometry rotated z π/2 and y angles, position y .45. Cylinder default along y. Use rotation z PI/2 then y angle. Add ends? cylinders with 7 radial segments, flat shading, materials perhaps log bark and separate cylinder ends? Geometry groups can use material array for side and caps. CylinderGeometry supports groups side, top,bottom. Use [barkMat,endMat,endMat] with Mesh. Cross logs stacked slight y differences and charred inner surface? Nice add small end rings not necessary. Additional logs around clearing as seats: 3 larger logs at positions with cylinders along ground and stumps. Requirement a few rocks and logs around campfire center. Stone ring and crossed firewood plus log seats.
Flames: stylized low poly layered meshes. Use custom geometry teardrop low-poly using lathe? ConeGeometry with radius and height, 6 radial, perhaps manipulate vertices to taper and curve. ConeGeometry standard centered vertical with tip at top? ConeGeometry radius bottom at -h/2 tip +h/2. I can animate scale, rotation, position. Multiple cones / custom poly flame geometry orange/yellow with emissive materials, transparent additive? Should visually flames organic not just static cones. Create 7 flame tongues with custom low-poly geometry using BufferGeometry rings vertices: base ring at y0 with radius; mid ring at y h*0.42 offset x; tip at y h offset. Triangles, flatShading. Each flame material MeshBasicMaterial color orange/yellow perhaps toneMapped false, transparent true opacity .9, blending AdditiveBlending depthWrite false. Layer outer red-orange large 1.7 high, inner yellow. Add point light at y 1.8. Animated via sin noise scale y x, rotation, position and per-flame phase. Should shadow from actual fire? light castShadow true but flames should not cast to avoid weird. Logs and rocks cast. Can add flame core point light perhaps warm secondary no shadows. Use two nested geometries with outer and inner materials. At night, visible bright. Perhaps shader flames custom? Simpler mesh with emissive MeshStandardMaterial and additive layers. MeshBasicMaterial no lighting, glowing appearance; CSS not bloom. Could use EffectComposer UnrealBloom from addons but additional passes complexity and performance; avoid or use sprite radial glow. Generate CanvasTexture radial gradient for glow planes as sprites. Flame glow Sprite at (0,1.2,0), scale 5x5, additive, opacity .3; flicker scale opacity. Warm ground illumination from point light.
Sparks: simple particle system BufferGeometry positions, colors/sizes. PointsMaterial size .06, vertexColors, transparent, opacity .9, additive blending, depthWrite false. 90 particles each with state age, life, x y z velocities. Respawn random, update positions per frame, rise and drift, fade requires per-particle alpha impossible PointsMaterial unless custom shader. Use custom ShaderMaterial with attributes aSize,aAlpha and point sprites radial soft circles; vertex gl_PointSize adjusted pixel ratio and depth. fragment circle smooth, color, alpha; additive. Could update color attribute perhaps per particle tone. Make 110 sparks. positions reset with base around 0 radius .4 y .7, velocities vx,vz small, vy 1.2-2.8, life 1.2-3.5 sec; age initial random so immediate sparks visible. Update dt; gravity small; opacity based on life; scale decreases near end. Use position and alpha. This is simple particle system though shader. Add subtle smoke particles? Not required, could add translucent low poly smoke puffs rising, but visual perhaps. Use 6 Sprite smoke with generated radial gradient, animate upward with opacity; night smoke gray; optional. Since sparks and flame enough. Add fireflies around clearing perhaps points gentle green? Not asked, might add 25 subtle golden motes. Could be visually nice but not distract. Stars: create points on sphere radius ~90 excluding below horizon. Since fog affects PointsMaterial default fog true; set fog false. 900 stars? low-poly vibe use small cross/dots. Use BufferGeometry with positions distributed upper hemisphere; PointsMaterial size 0.22 sizeAttenuation true, transparent .9, depthWrite false fog false. To ensure sky stars visible, scene.background color #050913, no sky sphere needed but could large inverted sphere gradient via ShaderMaterial to give deep blue horizon. Add skydome SphereGeometry(120,32,15) BackSide with shader gradient using world position y; fog false, depthWrite false. vertex varying world direction; fragment mix bottom #101c2b to top #02040c with subtle horizon. Stars placed radius 90, sky at 110. Moon sphere at e.g (-35,32,-55) with MeshBasicMaterial #fff4cf fog false. Sphere radius 3, low-poly perhaps SphereGeometry(2.6,16,12), flatShading false. Add halo sprite radial texture scale 18. Make sure visible initial camera orientation: camera at (15,10,18) looking origin, direction towards negative both; far background in front beyond fire roughly z=-? Moon placed (-24,26,-40) perhaps projected upper left. Camera (14,9,17) target (0,1.5,0), view forward (-14,-7.5,-17); moon vector from camera (-38,17,-57) has similar direction. Good.
Add mountains silhouettes? Not required, forest enough. Distant tree line 60 trees. Fog linear near 18 far 58 perhaps distant trees fade. Ground plane to 90, fog. Sky dome material fog disabled. Scene.fog = new THREE.FogExp2('#071018',0.022) or Fog('#071019',20,65). Linear near 18 far 60. Trees at radius 10-36. Distant fade at 45. Nice. Ground same fog.
Lighting: AmbientLight cool low .25, HemisphereLight sky #29405e ground #10130e .55, DirectionalLight moon #7799cc .7 pos (-20,35,-15), castShadow perhaps true with shadow map 1024 and camera +-30. But point light castShadow requirement satisfied, directional can add shadows to create moonlight; performance fine one directional + point 6 passes. If use instanced tree meshes both casts, okay. Point light distance 22 decay 2 intensity 70? In r155 physical lights use intensity candela; PointLight color intensity 55 distance 24 decay 2; with toneMapping exposure 1.2. Should see. Common three r170 PointLight intensity 60 for warm area. set renderer.toneMapping=ACESFilmicToneMapping, exposure 1.1; outputColorSpace SRGB. Ambient .35, hemi .6. Fire point light 80 perhaps strong. Flicker intensity = 75 + sin*12+ random smoothing. Warm #ff7a24. Position y 2.2. PointLight shadow.mapSize 512, near .2 far 30, bias -0.002, normalBias .04, radius 2.5 (radius ignored PCFSoft? works?). shadow.camera.far=30. Warm light dynamic shadows.
Logs outside fire and rocks: make geometry reused. Seats:
- log at (4,0.55,3) length 4 radius .48, along perhaps x, rotate y .3 etc. Camera initial from (14,9,17) means seat front-right might obscure fire if at z 3? position around clearing (5,0.5,1) and (-4,0.5,4),(-4,0.5,-4) etc. Leave front center unobstructed.
- stumps perhaps 4 cylinders. Requirement a few logs around campfire center; include 3 seat logs. Add cut ends using material array light brown, sides dark bark. CylinderGeometry radial 9, open? cap.
- rocks scattered 18 random at radius 4-10 avoiding fire/log paths. Use DodecahedronGeometry(.6,0) random scale, color via material variations. Could instanced rock mesh 22. Individual meshes few fine. Stone fire ring individually 12 dodeca. set y .3.
Ground detail: scattered low poly grass tufts/cones around non-clearing, perhaps 90 little triangular blades dark green. Instanced ConeGeometry .12,.8,3 at positions radius 7-25; nice but fire area open. Add small mushrooms? no need.
Composition UI: Header top-left with small label "INTERACTIVE NIGHT SCENE" and title "Emberwood", subtitle "A quiet clearing, after dark." UI dark translucent gradient, line. Top-right small controls card showing "DRAG Orbit", "SCROLL Zoom" etc and sound? no audio. Bottom center "Drag to explore • Scroll to zoom • Space pauses" perhaps minimal. Buttons perhaps camera reset and pause animation accessible. Task only orbit controls. I can add small control bar with buttons "Pause motion", "Reset view", "Fire" perhaps range? Keep all interesting visible. Should not have overlays dominate. A tasteful title and footer. On mobile hide subtitle/controls card, title smaller. Should clear user interactions. AutoRotate controls enabled initial with speed 0.25? Requirement no self playing relevant, but scene alive. OrbitControls autoRotate perhaps subtle and stop on user start, resume after idle? Could set autoRotate true with autoRotateSpeed .25 and pause toggle. "Drag to orbit • Scroll to zoom • R reset" footer. Control limits: minDistance 7, maxDistance 36, minPolarAngle .18, maxPolarAngle Math.PI/2 -0.06 (~86.4°) so camera stays above ground. But target y=1.2; at max distance 36 and polar angle 86°, camera y target + 36*cos86=3.7 >ground. minPolar .25 prevents straight top. enablePan false to avoid target underground, or enablePan true limited? Simpler enablePan=false, camera always above. maxPolarAngle = Math.PI/2 - 0.12, target y 1.1 gives camera >1.1. Near ground but okay. minDistance 6 max 32. Damping. mouse/touch.
Canvas accessible full width height 100%; body overflow hidden. Resize listener uses container clientWidth/height or window.inner. Pixel ratio min(devicePixelRatio,2) perhaps for perf 1.75. No external fonts. Use system font.
Perhaps include loading fallback? Three module loads network perhaps screenshot at 3 sec likely. First paint meaningful requirement can show overlay title and canvas background but scene loads quick. Add a loading indicator hidden once init; but imports loaded before script executes, initial UI visible. Could have CSS scene fallback. Fine.
No errors. Make sure no localStorage etc. Avoid alert etc.
Potential issue import map exact format expected: use exactly script tag JSON. Then `<script type="module"> import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js';`. No other URL. Good.
Detailed implementation.
Seeded RNG:
let seed=1337; function rand(){ seed = (seed*1664525+1013904223)>>>0; return seed/4294967296; } function range(a,b)... deterministic.
Renderer:
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.05;
renderer.outputColorSpace=THREE.SRGBColorSpace;
document.getElementById('scene').appendChild(renderer.domElement);
Scene bg color perhaps sky dome. Camera fov 48 aspect near .1 far 180 pos(13,8.5,16) target(0,1.2,0). At viewport 375 portrait, same fov vertical leads narrow horizontal; camera farther perhaps adapt initial on resize? At first if aspect<.8, set camera pos scale 1.25 or fov 55. Could define initialCamera position based aspect, reset accordingly. For mobile initial set camera (16,10,21), target (0,1.4,0) and fov 52. Desktop (13,8.5,16) fov 46. The full scene and fire visible. UI card top-right. Add lower text.
Sky dome shader:
vertexShader varying vec3 vWorldPosition; void main(){ vec4 worldPosition=modelMatrix*vec4(position,1.0); vWorldPosition=worldPosition.xyz; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}
fragment varying; uniform topColor,bottomColor,offset,exponent; float h=normalize(vWorldPosition).y; float f=pow(max(h,0.0),0.65); color mix(horizon, top,f); perhaps below horizon same. Add subtle band? Could just use large sphere and MeshBasic vertex colors gradient via geometry to avoid shader compile issue. Shader simple. Sky follows camera? At radius 115 centered 0; camera max 32 within dome. Fine. side BackSide, depthWrite false, fog false. renderOrder -10. Stars at r=100 (inside sky 110), moon r etc. Sky and stars far < camera far 220.
Stars generation upper directions:
for i 0..799: theta=random*2pi, y=range(0.05,0.98), r=sqrt(1-y*y); pos(r*cosθ*radi,y*radi,r*sinθ*radi)+ perhaps y offset 0. Star colors mix pale blue and warm. PointsMaterial size=.18 sizeAttenuation true. At dist 100 angular .1px perhaps too small: size .45 yields ~2 px at fov 46 and 800 height: .45/100*~900=4? Three gl point size size * scale / -mvZ, scale=height/2 ~400 -> 1.8 px. good. use .35. Additional bright stars perhaps second Points geometry 45 with size .7. Star material opacity .8. no twinkle necessary but can animate material opacity subtle .78+.08 sin.
Moon geometry at (-30,28,-48) distance ~63 from origin; radius 2.5 angular ~3.2 degrees; screen size ~50px. Nice. Low poly sphere 24x16 not low poly requirement only trees etc; moon can smooth. Use MeshBasicMaterial color #f6edc8 fog false toneMapped false. Add directional light from moon pos normalized. Halo sprite with canvas 128 radial gradient (center rgba(210,225,255,.32) to transparent), SpriteMaterial map, transparent, blending additive, depthWrite false, fog false? SpriteMaterial has fog property true default? set fog:false, toneMapped:false. scale 15,15,1 position moon. But sprite draw order relative moon: halo behind if depthTest true at same center could overlay moon center; radial center .24 tint, sprite may cover moon slight. Set sprite.position moon pos *0.99 perhaps behind? Same; material depthWrite false and moon depthWrite true; render order -? Set halo renderOrder=-1 and moon=0; transparent renders after opaque regardless, so halo overlays but subtle. Could set halo center low alpha .18. Add smaller ring perhaps.
Mountains not needed. Star and moon no fog.
Ground geometry vertex color:
const groundGeo=new THREE.PlaneGeometry(100,100,40,40); groundGeo.rotateX(-Math.PI/2);
const pos=groundGeo.attributes.position;
colors=[];
for i: x=pos.getX,z=pos.getZ,r=hypot;
height = r<7 ? -0.08 : (noise functions)*Math.min((r-7)/8,1)*0.65 -0.1; deterministic using sin x*.23 etc. pos.setY.
color base depending noise: new THREE.Color('#162922') etc offset. Can use color.setRGB linear? Three Color hex converted to linear; lerp colors works.
material MeshStandardMaterial({vertexColors:true,flatShading:true,roughness:1});
ground receiveShadow=true.
Add clearing mesh custom:
function createClearing() { const segments=40; vertices center and rings? To get facets and varying heights use BufferGeometry triangle fan positions; center 0,.005,0; ring points with radii 7.5+sin* .6 + rand .4; for each tri center p_i p_i+1, uv optional not needed; compute normals; vertex colors per triangle slightly. Use MeshStandardMaterial color '#332b22' roughness 1 flatShading true perhaps vertexColors true. Since Mesh at y=0.02. Could just CircleGeometry(8,36), modify vertices xy? CircleGeometry lies XY, rotate. Perimeter radii. After rotate, each ring vertex positions etc. Center triangle fan. easiest BufferGeometry explicit. Non-indexed, color per vertex. center y=0, ring y=random -.02. Slight earthen patch.
Fire ring stones placed radius 1.45, scale .35-.5, y .28. DodecahedronGeometry(1,0), scale nonuniform .6 etc. Ring around logs width 3.0. Fire logs cross length 2.5 radius .18. Flame base y=.55.
Logs:
function makeLog(length,radius,position,angle,barkColor) { const geo=new THREE.CylinderGeometry(radius*0.85,radius,length,8,1,false); // default y axis.
const mesh=new THREE.Mesh(geo,[barkMat,endMat,endMat]);
mesh.rotation.order='YXZ'; mesh.rotation.y=angle; mesh.rotation.z=Math.PI/2; mesh.position...;
Potential orientation: Euler order default XYZ: rotation z then y? Matrix from Euler XYZ yields rotated y axis by z -> vector? Cylinder y axis after rotation z 90 => (-1,0,0), then rotation y angle rotates x in xz. Good.
mesh.castShadow=true receiveShadow=true.
Could add bark bands: two thin torus? no need.
Seat logs use radius .42 length 3.8 y=.43; fire logs radius .18 length 2.6 y=.35-.6. End material perhaps '#8f6542' but at night. For charred fire logs bark '#24130d', ends '#4b2a17'. For seat bark '#3c2a1d', ends '#7d5b39'. Use geometry groups works: CylinderGeometry groups materialIndex 0 sides,1 top,2 bottom yes.
Stones instanced? Use individual 30 mesh drawcalls plus shadow. Fine. share geometries and materials. randomize material from array. Around fire ring 12. Scatter 16 at r 4-11. Some big rocks at r8.
Grass tufts instanced 160 at radius 8-29: geometry ConeGeometry(.18,.75,3), translate? Default centered; matrix position y height/2 plus ground approximate. But ground undulates y perhaps function getGroundHeight(x,z) same used. Use 2 crossed triangular blades? one cone is spike, at small sizes okay. Better custom blade geometry tetra? Cone 3 gives triangular low poly tuft. Scale x 1, z 1. Random dark green instance colors. castShadow false, receiveShadow true. 140 matrix. They add visual.
Trees instanced:
count perhaps 64 to exceed 30. Place radii random distribution 10-37. I want random around clearing, no overlaps and no trees on fire. Rejection min distances to previous >1.6? 64 in annulus area π(37²-10²)=3985, enough. Ensure initial foreground not blocked near camera line? Trees radius >=10; camera at r20 perhaps inside forest, looking center, trees behind camera perhaps but if tree between camera and fire along line at r12 could obstruct. I can deliberately clear view corridor near initial camera angle (towards position angle atan2(x,z)=~0.68 rad): for tree positions if r<19 and angular difference to camera angle <.22 skip. This makes fire unobstructed initial. But camera orbit can see trees. Random distribution 64. Use radial = 10 + Math.pow(rand(),.72)*28. Some far to 38 fog. height range 5-11, trunk h .9? Conifer crown lower at trunk top etc. Use trunk cylinder radius .22-.38 height 2.0-3.2. Lower cone radius 1.4-2.2, height 3.4-5.2; mid 1.0-1.7 h3.2-4.4; top .6-1.2 h2.5-3.7. total height around trunkH+? Place lower center y=trunkH*.75+lowerH*.45? Should natural.
Let H=range(6,11); trunkH=H*.32; lowerH=H*.48; lower center y=trunkH+lowerH*.25? Cone extends center±h/2, bottom at center-h/2. Want bottom y=trunkH*.45. lower center=trunkH*.45+lowerH/2. top lower y = bottom+lowerH = .45 trunkH+.48H. Mid bottom=.45trunkH+.27H, midH=.42H; top bottom=.45trunkH+.52H, topH=.32H. Total=.45*.32H+.52H+.32H=.984H. good. radius lower=H*.21, mid=H*.165, top=H*.11. trunk radius=H*.035. trunk center trunkH/2.
Geometry unit ConeGeometry(1,1,7,1) flat. For each layer matrix compose with position x,y,z, quaternion rotationY(random), scale(radius,height,radius). Lower/mid/upper different rotation for offset asymmetry? Same x,z, random Y. add per-layer offset small random .1. Use same InstancedMesh and setMatrixAt. setColorAt based on palettes (#163b32, #1c493b, #254f3e etc) but night lighting makes dark. Colors dark green #1d3a32 to #2d5140. Since scene fog and moonlight, enough. Lower material roughness 1 flatShading; vertexColors automatically instanceColor handled. use material color white. Trunks color random browns.
For trees matrix, geometry unit cylinder default height1; scale(radius,trunkH,radius), position y=trunkH/2 + groundY. For cones y as above.
InstancedMesh.instanceMatrix.setUsage(THREE.StaticDrawUsage) default okay. castShadow true for trunk/crowns? Fire point shadow draws all 64 but instanced efficient. receiveShadow true. Should instancing shadow works yes. Directional shadow perhaps map bounds 42. Could turn off moon directional castShadow to performance and fire point only. Trees cast from point light within far 30. Fire shadows long dynamic. Nice. Moon directional no shadows means tree lighting basic but point shadows and ambient. I could cast directional shadows too, 4 drawcalls only due instancing, plus rocks logs. 64 instanced =4 calls. Fine enable directional castShadow true mapSize 1024, shadow camera left/right ±30 etc. Gives moon shadows but intensity .45 subtle. Shadow bias -0.001 normalBias .04. This enriches.
PointLight shadows with InstancedMesh and Material flatShading okay.
Trees far fog. Use scene.fog = new THREE.Fog('#08131b', 17, 58); background sky top different but fog color matched horizon. Ground far fades. Tree material fog default true.
Fire glow sprite generated:
function makeRadialTexture(stops) { const c=document.createElement('canvas'); c.width=c.height=128; ctx createRadialGradient(64,64,0,...64); etc; return new THREE.CanvasTexture(c); } texture.colorSpace=THREE.SRGBColorSpace perhaps for sprite colors. Add sprite centered (0,1.2,0), scale (5,5,1), material blending Additive, depthWrite false, opacity .28. Since billboard always faces camera, looks bloom around flames. Might cover flames with opacity; render order perhaps 2; flames transparent render order 4. Set glow.renderOrder=1, flame meshes renderOrder=3; transparent sorting by depth could override? renderOrder used first. good.
Flame custom geometry:
function createFlameGeometry(radius,height,segments=7){
const positions=[];
const rings = [
{y:0,r:radius,ox:0,oz:0},
{y:height*.35,r:radius*.78,ox:radius*.14,oz:0},
{y:height*.68,r:radius*.38,ox:-radius*.12,oz:radius*.05}
];
Should have triangles between rings and tip. Non-indexed easiest build vertices each tri. Each ring points angles with irregular? deterministic per geometry random but same each tongue perhaps. I can create indexed positions and indices then .toNonIndexed and compute normals; but MeshBasic no normals needed though flat shading silhouette. Build array rings vertices plus tip, indices sides and bottom. Bottom not visible but add. geometry.setIndex; computeVertexNormals; perhaps nonindexed to faceted if using standard. Use MeshBasicMaterial with flatShading irrelevant. For faceted color could use MeshStandardMaterial emissive + color. But point light on flames could overexpose. MeshBasic with vertex colors assign different orange based y to produce gradient. Create geometry with color attribute per vertex based on y, but indexed shared vertices color consistent. Materials vertexColors true color white. Yet each flame material colors can differ. Simpler ConeGeometry(radius,height,7) translated y height/2; modify positions x based y to bend. ConeGeometry has multiple vertices base and tip duplicated; use position attr loop y normalized; x += bend*Math.pow(t,2); scale x/z variation. This has base at -h/2, tip +h/2. translate(0,h/2,0) -> base 0 tip h. Then alter x by curve perhaps after translation. compute normals. Use Icosahedron? Cone enough low poly. Add 7 outer tongues:
specs = [
{r:.68,h:2.4,x:0,z:0,phase:0,color:0xff5a13,opacity:.62},
{r:.4,h:1.8,x:.35,z:.12,phase...},
...
]
If additive blend many central overwhite; colors orange and opacity .45. Outer base material transparent .75 depthWrite false, blending THREE.AdditiveBlending. Inner yellow material normal transparent perhaps. 7 meshes can be nested all at same origin, z-fighting? different positions/scales. Use front side? Cone side only plus base. `side:THREE.DoubleSide` for flames visible inside. `toneMapped:false` retains colors but ACES bypass bright colors. With additive and toneMapped false, saturated. Could use outer normal blending to preserve orange and inner additive. Better main outer flames MeshBasicMaterial({color:'#ff6b1a',transparent:true,opacity:.82,side:DoubleSide,depthWrite:false,blending:NormalBlending,toneMapped:false}) and inner yellow Additive opacity .7. But sorting individual meshes can create artifacts; still stylized.
Alternative shader flame material vertical gradient, transparent edges? Geometry silhouette enough. MeshBasic color per vertex from deep orange bottom to yellow top? Typical fire yellow core at base, orange tips. Outer orange base #ff8a24, tip #ff3d08. inner pale yellow. I can assign vertex colors based y perhaps. Material color white and geometry colors set. Could create flame geometry with `color` each vertex: y=0 base color '#ffcf55', mid '#ff7a1a', tip '#e7340a'; with MeshBasic vertexColors true. MeshBasic supports vertex colors. Good. Additive and opacity .7. Inner mesh clone with material solid #fff1a0. Use central white-yellow core flame h1.4.
Animate each:
const base = mesh.userData;
const t=elapsed* (2.5+...) + phase;
mesh.scale.set(1+0.12*sin(t*1.3), 0.84+0.18*sin(t)+0.07*sin(t*2.31), 1+0.1*cos(t*1.1));
mesh.position.x=base.x+0.09*sin(t*.7); z similar; y=.48 + .03sin.
mesh.rotation.y = base.rot + .18 sin(t*.4);
mesh.rotation.z = .08 sin(t*.8); geometry along y so tilt.
Also material opacity flicker .65+...
Don't regenerate random every frame for light to avoid jitter? fine use value noise from sin. fireLight.intensity=65+10sin(t*13)+6sin(t*23.7)+4sin(t*37.1); position x .08sin, z .08cos. Glow scale = 4.8 + .4 sin etc; material opacity=.23+.035sin.
Ember bed: small 20 dodeca or icosa spheres in ring center with emissive materials orange/red, scale .08-.18 at y .35, pulsing emissiveIntensity perhaps share material and vary. Use InstancedMesh? Individual 14 okay. MeshStandardMaterial color #451306 emissive #ff3c08 emissiveIntensity 1.8, roughness. Place random within radius .8. Since flame overlays. Add point light? main enough.
Also fire ring rocks receive dynamic light.
Smoke: create 7 sprites with radial gray texture, each state phase; at y 2+ progress*5, scale 1+progress*2, opacity (1-p)*.1, x drift. Additive? normal blending, color '#63707a', opacity .1. On night subtle. SpriteMaterial each separate for opacity. `depthWrite:false`, transparent, fog true? SpriteMaterial fog true. Start at y2 and ascend. They could obscure moon minimally. Smoke requirement not, but adds polish. Ensure no weird rectangles. radial texture generated with gradient center rgba(255,..0.22) to transparent. Each Sprite at fire; update. If camera orbit, sprites face. Set renderOrder 2. Fire sparks renderOrder 5.
Spark shader:
positions Float32Array(count*3), colors, sizes, alphas.
Shader:
uniform float uPixelRatio;
attribute float aSize;
attribute float aAlpha;
attribute vec3 aColor? Three ShaderMaterial doesn't automatically declare color attr if vertexColors? define `attribute vec3 color;` conflict? ShaderMaterial built-in attributes position, normal, uv; color only if vertexColors true defines? To avoid use custom `aColor`. set attribute 'aColor'. varying vColor/vAlpha.
vertex:
vColor=aColor; vAlpha=aAlpha;
vec4 mvPosition=modelViewMatrix*vec4(position,1.0);
gl_PointSize = aSize * uPixelRatio * (140.0 / -mvPosition.z); // if aSize world .06 gives .84px at 10? Should choose aSize .09 * pixelratio * (200/depth) =1.8px at10. At depth20 .9. Want 2-4px. use sizes random 0.05-.12 with 300 scale yields 1.5-3.6 at10. Good.
gl_Position=projectionMatrix*mvPosition;
fragment:
float d=length(gl_PointCoord-vec2(.5));
float alpha=smoothstep(.5,.05,d)*vAlpha;
if(alpha<.01) discard;
gl_FragColor=vec4(vColor,alpha);
Should toneMapped? ShaderMaterial not tone mapped unless include chunks; output perhaps renderer applies output colors? Shader material need `#include <tonemapping_fragment>` and `<colorspace_fragment>` if desired but not necessary? It may appear linear interpreted; choose values. Set transparent true, blending Additive, depthWrite false. `uniforms:{uPixelRatio:{value:renderer.getPixelRatio()}}`; update on resize. Sparks positions world. Age:
function resetSpark(i,initial=false) {
const p=i*3;
const angle=rand()*2π, rad=rand()*.55;
positions[p]=cos*rad; positions[p+1]=.7+rand()*.6; positions[p+2]=sin*rad;
velocities[i]={x:(rand-.5)*.35,y:1.2+rand*2.4,z:(rand-.5)*.35,life:1+rand*2.2,age:initial?rand*life:0,drift:rand*6};
if initial, advance position by age? Could just age random but positions base, first frame many at base; after 3 sec evenly distributed anyway. Better after assign simulate positions using age:
positions x += vx*age + sin(age*...)*.1; y += vy*age - .15age²; z...
alphas computed.
}
Update each frame if !paused:
s.age += dt; if >life reset.
positions += v*dt; positions x += sin(elapsed*2+s.drift)*dt*.12; y velocity perhaps s.vy *=? no store base; position y += (s.vy - .25*s.age)*dt.
alpha = Math.sin(Math.PI * normalized) perhaps start fade in/end out; `Math.max(0,1-n)` * min(1,n*8) * .9.
sizes static random perhaps.
colors static pick Color '#ffd37a' or '#ff7a24'; convert to linear? Three Color automatic linear, shader no colorspace conversion perhaps dim? Add include colorspace to convert. I can use Mesh PointsMaterial simpler with vertex colors, but alpha per particle custom is nice.
Set `frustumCulled=false` to prevent bounding sphere issues (positions dynamic).
Pause toggling: `let paused=false;` animation still controls update and render; if paused no flame/spark updates and controls.autoRotate=false. Button text Resume motion. Keyboard Space toggles only if target not button? window keydown, if code Space preventDefault, toggle. R resets. Orbit controls listens keys? no.
On pointerdown controls autoRotate false? I can add `controls.addEventListener('start', ()=>{controls.autoRotate=false; userInteracted=true; updateStatus?})`; when end set idle timer? Could auto rotate after 8 sec? Not needed. Initial autoRotate = true speed .18, screenshot changes slight. Pause button if paused then autoRotate false. Reset camera restores autoRotate = !paused. Buttons pointer events not propagate to canvas because separate HUD.
Timeline using THREE.Clock; dt=Math.min(clock.getDelta(),.05), elapsed+= paused?0:dt. requestAnimationFrame. If clock delta after tab huge cap.
Moon/stars static; animate stars opacity using elapsed even paused? no.
Controls.update() each frame damping and autorotate. Render.
`renderer.setAnimationLoop(animate)` could use; requestAnimationFrame simple.
Should initial render before first RAF? animate() call once request; first paint within. Set sizes etc.
Reset camera:
function setInitialView() {
const mobile = innerWidth<600;
camera.position.set(mobile?17:13, mobile?10:8.5, mobile?21:16);
controls.target.set(0,1.25,0);
camera.fov=mobile?52:46; camera.updateProjectionMatrix();
controls.update();
}
On resize should not reset user view. Only adjust fov perhaps if crossing? `camera.aspect = w/h; camera.updateProjectionMatrix`. Keep initial set before.
Mobile UI max width etc.
Accessibility: canvas aria-label? renderer.domElement.setAttribute('aria-label',...); role img? Orbit interaction use `tabindex=0`. HUD buttons labels. Add small note "LIVE SCENE" with pulse. Color contrast. `user-select:none` on overlay but buttons okay. CSS pointer-events none globally overlay except .panel buttons. No click-to-start necessary because not game. Requirement instructions about keyboard after click only if demo uses keyboard. I can include keyboard shortcuts but no need display click to start. OrbitControls pointer works directly. Footer "Drag to orbit · Scroll to zoom · Right-drag disabled" etc. Since keyboard optional no "click or key to start" required because not game. Could show "Drag to orbit · Scroll to zoom" clearly.
Could add camera presets via button "View" cycles? Not needed. Buttons `Pause motion`, `Reset view` visible bottom right. Add mini legend flame info "62 pines · living fire" perhaps aesthetic. Top-left title.
Aim high-quality low poly. Could add foreground log and grass to frame. I need ensure camera stays above ground even if controls target y1.25 and maxPolar=π/2-.08: camera y >=1.25 + dist*.08, so yes. Ground outside can height up to .5 but camera near fire target; radius max38 and at low angle camera y4.3 > ground. minPolarAngle .15. maxDistance 34. Near plane .1. enablePan false.
Fog far 58, camera far sky unaffected. Renderer fills.
Check tree color and scene illumination. Use AmbientLight color #263d57 intensity .65, Hemisphere #637fa5/#11160e intensity .65, Directional #a8c8ff intensity 1.15 perhaps physically correct directional no units. ACES exposure .95. Fire PointLight intensity 85 distance 26 decay 2, at 5m gives 3.4 lighting? In three physically correct, intensity candela and standard material, enough. Ambient ensures trees visible. Emissive flames toneMapped false. Fire light shadows.
Potential shadow acne: pointLight.shadow.bias=-0.0015, normalBias=.035. directional bias=-.0005 normalBias=.05. ground receive. Camera near etc.
Moon light at (-25,35,-30) target origin; directional shadow camera ±28, near1 far90, mapSize 1024. position copy moon.position perhaps(-30,32,-45), direction at distance. `moonLight.target.position.set(0,0,0); scene.add(moonLight.target);`.
Sky shader needs `depthWrite:false`, side BackSide; if `depthTest:false` could render over all depending renderOrder, so keep depthTest true and renderOrder=-100. Since sphere is far and drawn opaque before objects, depth writes false means doesn't block stars? Stars at radius 95 vs sky 110; both behind objects. Stars transparent drawn later, depth test against objects, good. Moon opaque at 60 drawn among opaque; sky renderOrder -100 ensures background. scene.background '#040811'.
Sky shader fragment:
float h=normalize(vWorldPosition).y * .5 + .5? Want horizon at y0. `float h = normalize(vWorldPosition).y; float mixFactor = smoothstep(-0.12,0.75,h); vec3 color=mix(uHorizon,uTop,mixFactor); float glow=pow(1.0-max(h,0.0),6.0)*.035 perhaps`.
Set top #02050d, horizon #152536. tone mapping? ShaderMaterial default toneMapped true? Custom shaders no automatic chunks, output direct. Colors linear passed via Color, no colorspace conversion perhaps appear too dark. Could use constants direct vec3 for desired sRGB? Better add `#include <tonemapping_fragment>` and `#include <colorspace_fragment>` at end to correctly output. Shader includes available. Set material.toneMapped=false? If include tone mapping regardless? Three injects defines; chunks use functions. Common:
gl_FragColor=vec4(color,1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
Works. If renderer ACES will tone map shader, horizon perhaps subdued. Fine. Stars PointsMaterial default toneMapped true perhaps. Moon toneMapped false.
Ground vertex colors: create Float32BufferAttribute colors. Set material color 0xffffff. colors in linear via THREE.Color. ground geometry color per vertex. Clear patch geometry set color attribute nonindexed. Should make sure normals orientation up. For triangle center, current ring point and next. Coordinates x,z, y up. Compute winding: at angle 0 p_i=(r,0,0), next=(r cosδ,0,r sinδ). Vectors p_i-center=(r,0,0), next-center=(r cos,0,r sin); cross a×b = (0*r sin-0=0, 0*r cos - r*r sin = negative y, ...), so triangle center,p_i,p_next normals down. Should order center,p_next,p_i for up. PlaneGeometry rotateX(-π/2) default plane normal +z transforms +z to +y? Rotation x -90: z axis to y positive yes. Good. Clearing indices reverse. Build positions via push center, next, current. Colors same per tri. Set normals compute. Mesh material side FrontSide; up.
Alternatively rotate x plane etc. I'll implement `for each tri push center, next, curr`.
Ground height function:
function groundHeight(x,z){
const r=Math.hypot(x,z);
const blend=THREE.MathUtils.smoothstep(r,6,13); // THREE.MathUtils.smoothstep(x,min,max)
return -0.12 + blend*(Math.sin(x*.31)*.24 + Math.cos(z*.27)*.2 + Math.sin((x+z)*.17)*.16);
}
At center ground -0.12. clearing y=-.05 perhaps above. Trees positions groundHeight. Fire base ground y0. Ground at r0 -0.12, clearing at -0.04. Rocks y based 0.02. Seat logs at y groundHeight + radius etc but within r6 blend 0 ground -.12, log y .35. Good. Put clearing y=-.055 perhaps center and outer variable align. Ground plane at -0.12 near center. clearing above by .065.
Ground mesh receiveShadow. Clear patch receiveShadow.
Trees at radius >=10 where ground height nonzero. Use groundHeight in matrices.
Scattered rocks y = groundHeight(x,z)+scaleY*.35? Dodeca radius1 scaled sy perhaps .35-.75, geometry extends ±1 so center should y+sy*.65 to embed. For fire ring geometry radius 1 scaled .45,.32,.5, center y=.16? ground clearing -0.05, stone bottom -.16? center .22 and sy .32 bottom -.1 slightly embed. good. Dodeca random rotations.
Fire logs default y .3 and radius .18 bottom .12 above clearing; flames base .35. Add ember stones at .15. Fire ring around radius1.5. Seat logs center y groundHeight+.42, bottom ground. Fire logs y .22,.4,.58 perhaps three layers crossing. If length 2.4 and ring radius1.4, good. fire log mesh material array shared. rotate angle. Add small branch perhaps.
Log geometry cap material order check CylinderGeometry: groups: side 0, top cap 1 if cap, bottom 2. yes.
Log position for seats:
makeLog(4.1,.46,new Vector3(-4.5,groundHeight(-4.5,2.8)+.43,2.8),-.35)
makeLog(3.5,.4,(4.2,ground+.38,-2.8),1.1)
makeLog(3.2,.38,(-3.4,ground+.36,-4.6),.15)
At initial camera (13,16), front right seat at 4.2,-2.8 far side? camera z positive, front seats -4.5,2.8 left front; okay frames.
Could add stump at (4.8,3.7) but might obscure. 2 stumps near edges.
Rocks random positions no on logs? just random 4-10 can overlap seat logs but okay; use min distance to seat centers perhaps skip.
Grass only outside r7.5; random all. Use 110 instances. Each cone y ground+height*.35 perhaps embed. Matrix with rotations. Colors.
Instanced mesh `instanceColor.needsUpdate=true` after setColorAt. setMatrixAt marks? after loop `instanceMatrix.needsUpdate=true`.
Tree matrices reusable `const matrix=new THREE.Matrix4(), quaternion=new THREE.Quaternion(), scale=new THREE.Vector3(), pos=new THREE.Vector3();`
quaternion.setFromEuler(new THREE.Euler(0,rot,0)); Use one quaternion reused.
`matrix.compose(pos,quat,scale); trunk.setMatrixAt(i,matrix);`
Each layer distinct. `treeTrunks.count=treeData.length` constructed exact count after positions generated. Could generate data first then instanced.
Tree placement min separation:
const trees=[]; let attempts=0; while(trees.length<62 && attempts<2000){r=10+...; a=rand*2π; x=cos a*r,z=sin a*r; if corridor...continue; if trees.some distance<1.8) continue; trees.push...}
With far radius up to 38, fine. Initial camera at angle atan2(z? x,z) corridor formula: camera direction from origin angle atan2(cameraX,cameraZ) in x=sin angle,z=cos angle; my x=cos a,z=sin a, camera angle a=atan2(cameraZ,cameraX)=atan2(16,13)=0.889. Define viewAngle=Math.atan2(16,13). If r<20 and abs(atan2(sin(a-viewAngle),cos(...)))<.20 skip. But tree behind fire opposite not issue. Good.
Random at least 30 if attempts stops unlikely; exact 64.
Tree top perhaps far above viewport? Camera looking at y1.25 and nearest tree at r10 height10; fills background nice. Fog near 18; nearest clear.
Moon at x=-28,y=30,z=-45; camera initial forward direction to origin (-13,-7,-16) moon direction from camera(-41,21.5,-61), angle vertical ~17° vs view downward ~20°, total relative 37°? Wait camera points downward from y8.5 to target1.25 over horizontal 20.6 = -19.4°. Moon above horizon direction from camera y21.5/horiz73 = +16.4°. Relative vertical 35.8°, camera FOV46 vertical ±23°, moon out of frame! Should account initial camera looking down at fire. To show moon, place lower angular in view or camera less down / wider FOV. Typical campfire scene camera y7 looking at2, horizontal 20 -> down14°, moon altitude perhaps 10° => relative24 just top. FOV50 ±25. Could place moon altitude ~6° so relative20°, visible near top. But moon position y e.g 18 at distance75 altitude from camera ~8°, relative22°. If fov50, visible. I want stars too. Or camera look target y2.3, position y8, down15°. Moon y20 dist65, altitude~10°, relative25. Perhaps use FOV52. Desktop camera at (12,7.8,16), target(0,1.8,0), down16°, FOV50. Moon at (-24,18,-42) from cam horizontal68, y10 => alt8.5°, relative24.5 at edge. Place y14 (alt5°) relative21°, visible. Moon just above trees perhaps trees height10 at distance20 alt etc could occlude. Place moon high but adjust camera to look less down by target2.4; down14°, fov52. Moon y20 alt10 -> relative24 within26. Good. Fire still visible lower center (target2.4 means fire at y1 -> -4°). Nice. Set camera desktop(12,8,16), target(0,1.8,0), fov50 perhaps moon at(-26,19,-45), from camera horizontal72, y11 ->8.7°, camera down16.9°, relative25.6 borderline. Set target2.2 down15.6°, FOV52 =>26, still border. Moon y17 alt6.8 total22.4 visible. Moon altitude world from origin at atan(17/52)=18°, but from camera ~7°, due camera elevated; yes. It appears in upper region. Trees top at y10 distance from camera perhaps 30, alt4°, could occlude near moon direction, but moon behind trees realistic. Moon at position(-23,19,-42), camera relative horizontal68, alt9°, down16 =>25. choose y16 gives alt5°, total21. Good. Use moon(-28,18,-48). Horizontal from camera sqrt40²+64²=75, alt7.6°, down15.7 ->23.3 within26. Top left. Good. Camera far180, moon distance 75. Stars visible mostly above.
Alternatively implement moon as sprite attached to camera? no.
Should think exact projected horizontal: forward toward origin normalized(-.6,-.8), moon dir(-.53,.85? from camera -40,-64 normalized xz(-.53,-.85)), angle horizontal ~7°, so center leftish? It aligns near center not left due both negative proportion. Camera forward horizontal x/z ratio .75; moon ratio .625 close. So moon near horizontal center. Should upper-left, shift moon more left x=-48,z=-35: from camera(-60,-51), forward xz(-.6,-.8), angle difference atan ratios 60/51=1.18 vs .75 ~18° left. Horizontal FOV at 1280 aspect1.6 and vertical52 => horizontal77° ±38, visible. vertical from camera y10 /79=7.2 plus15.6=22.8, upper-left. Moon pos(-42,17,-38) gives dx-54,dz-54 ratio1, angle relative ~8°, not much. x=-48,z=-30 ratio60/46=1.3 angle ~16°, good. Distance from origin 56, moon y17. From camera horizontal76 alt7°, total23. top-left. Set moon(-42,20,-48) perhaps aligned. To get left, use (-48,20,-28): dx-60,dz-44, horizontal74.5 alt8.7, down15.6=24.3 near top. y17 alt6.4=22.0. good. Moon (-48,17,-28), sphere radius2.3 appears size ~27? vertical pixels 800/(2tan26)=820, size=2*2.3/75*820=50px. Great.
Moon in world low altitude 17°, but okay.
Camera desktop perhaps initial(12,7.5,16), target(0,1.5,0), down16.2; same. Set target y1.6. Fire at 1 is near lower center. Fov50. Moon y15? To ensure screenshot moon visible use fov52. A low-poly scene with wide perspective works. Camera at (13,9,18), target(0,1.5,0), down18°, fov52. Moon at (-35,15,-42) from cam dx-48 dz-60 horizontal77, alt4.5 +18=22.5, within26. Horizontal ratio.8 vs camera.72 nearly center. Add x=-50,z=-30 gives left. from cam(-63,-48), horiz79, alt4.3 +18=22.3. Good. Moon(-50,15,-30). Scene directional from same. So set camera(13,9,18) and target(0,1.6,0), fov52. Mobile farther (17,11,23) fov55. Fire small but still enough at desktop center width 200? Fire h2.5 at distance24 =>85px. Could be bigger perhaps camera distance22.6 and fov52, height 2.5 =>95px. Good. Clearing radius8 at depth23 gives ~600px wide. Trees frame.
Could set camera(11.5,7.8,15.5) dist20, fire 120px; moon from cam relative horizontal etc. Should comfortable. Use (12,8.5,16) dist21.3, fov50. Fire h2.4 projected 100px. Fine. Moon at (-45,14,-30) relative(-57,-46), horiz73, alt4.3; down17.5 total21.8 within25. top left. Great. Set moon(-45,14,-30), radius2.2. Tree line behind may occlude moon partly but halo visible. Stars.
Sky dome color horizon #101c2c. Fog #08131b slight mismatch; ground fades dark. Good.
AutoRotate changes camera angle slowly. At 3 sec ~0.2°? OrbitControls autoRotateSpeed .25 means 30*(.25)? Default 2.0 =30sec/orbit? Formula 2π/60/60*speed each frame => at speed .35 orbit ~171 sec. In 3s 6°, may shift moon slightly but still visible. Fine. Use .18 => slow. Visible flames/sparks enough.
Add occasional shooting star? not required. Avoid.
Pause button and auto rotate status:
HTML:
<div class="ui">
<header class=brand>
<div class=eyebrow><span class=dot></span> LOW-POLY NIGHTSCAPE</div>
<h1>Emberwood</h1>
<p>Stay a while. The forest is listening.</p>
</header>
<aside class=controls-help>
<div class=help-title>EXPLORE THE CLEARING</div>
<div class=help-row><span class=mouse-icon>...</span><span>Drag to orbit</span></div>...
</aside>
<footer class=bottom-bar>
<div class=scene-stats><span>64 PINES</span><i></i><span>LIVE FIRE</span></div>
<div class=actions><button id=pause>Pause motion</button><button id=reset>Reset view</button></div>
</footer>
</div>
<div class=vignette></div> pointer-events none; perhaps vignette overlay can darken canvas edges aesthetic.
`#scene` fixed inset 0. body background.
Top UI pointer-events none, actions pointer-events auto.
On mobile controls help hide or compact bottom? Keep footer instructions. Bottom center small "DRAG TO ORBIT · PINCH TO ZOOM" displayed mobile via CSS alternate.
No huge title; h1 32px.
Perhaps top right "NIGHT 03 / 12:48 AM" adds scene aesthetic but irrelevant. Keep controls.
Use inline SVG icons? Output file HTML, inline svg in markup allowed. Simple mouse outline via CSS. Avoid external icons.
Help row uses `<span class="key">DRAG</span>` etc.
Buttons glass with border. Hover.
At 375 width, brand h1 26 and top 20 left20, controls help hidden, footer left/right 14 bottom14 flex column? Stats hide, actions two buttons small. Tip fixed bottom 76 center. No overlap. Canvas full.
Loading badge could be removed after scene init. Since script module network perhaps title initial and background. Add `<div id=loading>Gathering embers…</div>` center; hide after first render with opacity then remove? On first scene immediate. Could skip to avoid screenshot overlay if import slow; meaningful background with title okay. Requirement scene visible at 3 sec likely network cdn. No need.
Performance adaptation: if devicePixelRatio >1.5 cap 1.75. Shadow 1024 point perhaps expensive but instanced. Use point shadow 512. 64 trees * 4 instanced = efficient. Particle 120. All good.
Set renderer.shadowMap.autoUpdate = true default needed flames light movement flicker but light position changes shadow maps each frame; dynamic logs static. Could freeze? requirement flicker casts shadows, dynamic light position makes movement. point shadow updates every frame expensive but okay 4 tree instanced and ~50 rocks/log draws x6 ~300 draw calls. Perhaps point light shadows plus directional ~360, standard desktop fine. Mobile potentially. Could limit `fireLight.shadow.autoUpdate`? Light shadow property no autoUpdate? renderer global. Keep.
I can set far 24 so trees beyond clipped per object but instanced mesh all one object and bounding sphere includes all, all rendered but vertices clipped per shadow. fine.
Set rocks scattered castShadow true; grass false.
PointLight shadow mapSize 512; directional 1024.
Star count 900 points one draw. Flame draw 9. Smoke 7 sprites (each draw). Fine.
Random helpers reused seeded; initialization consumes. Runtime uses separate Math.random for spark resets? Determinism not important; if using rand in reset each frame advances seeded state no other init after. Fine.
Better separate `random()` runtime alias Math.random? Deterministic sparks can use seeded. Fine.
Fire logs create individual `CylinderGeometry` each call but 6 only. Stones share geometry. Materials arrays. Geometry dimensions per log variable so new each. fine.
Flame geometry colors:
function makeFlameGeometry(radius,height,bend=0.2){
const geo=new THREE.ConeGeometry(radius,height,7,3,false); // heightSegments3 allows bend gradient and facets.
geo.translate(0,height/2,0);
const p=geo.attributes.position;
const colors=[];
const cBase=new THREE.Color('#ffd36a'), cMid=... cTip...
for each vertex:
let y=p.getY(i); let t=clamp(y/height);
p.setX(i,p.getX(i)+bend*t*t);
p.setZ(i,p.getZ(i)+Math.sin(t*Math.PI)*bend*.25);
const c = t<.55 ? cBase.clone().lerp(cMid,t/.55) : cMid.clone().lerp(cTip,(t-.55)/.45);
colors.push(c.r,c.g,c.b)
geo.setAttribute('color'...)
geo.computeVertexNormals()
return geo
}
ConeGeometry top tip has multiple vertices? yes cap etc. Base vertex colors yellow; outer base may look yellow. Material opacity .65. inner geometry no vertex colors? Separate material vertexColors false but geometry has color ignored. Fine.
Outer material transparent, blending Normal? Use Additive gives glow. I can create 6 with Additive opacity .45 plus central solid yellow opacity .95 normal to retain core. Use `depthTest:true`, depthWrite false. Additive sorting no issue.
Specs:
outer 0 central r.65 h2.35 x0 z0 color default bend .18 opacity .68
outer 1 r.42 h1.85 x.38 z.1 phase1.4
outer 2 r.38 h2.0 x-.35 z.12
outer 3 r.34 h1.55 x.1 z-.35
outer 4 r.3 h1.75 x-.12 z.38
inner central r.36 h1.45 y.48 material '#ffe49a' opacity .9
inner small r.2 h1.0 x.2 z.12 color '#fff3c2'.
7 meshes. All low poly.
Some flame geometries base at y0 and mesh.position.y=.42, logs top .65, base should .45 within logs. Fine.
`mesh.userData.baseScale=range(.85,1.1)` etc.
Use `flame.material.opacity = baseOpacity*(.88+.12 sin)`.
Ember bed materials perhaps two, update emissiveIntensity = 1.8+.6sin elapsed*8. MeshStandardMaterial shared all; fine.
Create embers geometry Dodecahedron(.11,0), 20 mesh. Random x/z .8; y .18; scale y .5. color material emissive. Set castShadow false.
Add charcoal chips perhaps.
Smoke update with own materials; start progress initial i/count so visible immediate. `const cycle=(elapsed*.12 + i/count)%1; y=2+cycle*5; opacity=Math.sin(cycle*Math.PI)*.07; scale=.8+cycle*2.8; x=sin(elapsed*.4+i)*cycle*.7`. If paused elapsed stops. texture same as glow? separate radial white. material color #718094. Normal blending. Might appear grey; opacity .09.
Stars twinkle: `starMaterial.opacity=.78+.08*Math.sin(elapsed*.7); brightStars...`
Moon perhaps float no.
Fireflies optional: create Points with positions ring 3-10 y .5-2.5, PointsMaterial #b8d887 size .055 opacity .5 additive; animate whole rotation and y? Points geometry static group rotation slow, material opacity. Could look like sparks away from fire; atmosphere. But user asks sparks, those visible. Add 24 fireflies golden/green to clearing edges with custom shader? More code. Not necessary.
Ground small fallen leaves perhaps triangles with ocher colors near logs. No need.
Camera reset key handling: `window.addEventListener('keydown', e=> { if(e.code==='Space'){...} if(e.code==='KeyR')... });` Instructions say iframe only receives keyboard after click; since footer lists shortcuts no need start. I can omit keyboard mention and only buttons, but keyboard nice. If include footer keys "SPACE Pause · R Reset", user knows click. No game so no click message needed. Buttons suffice. Avoid space event when focused button to not double toggle:
if(e.target instanceof HTMLButtonElement) return; (cross realm same).
Pause function:
paused=!paused;
pauseBtn.textContent=paused?'Resume motion':'Pause motion';
pauseBtn.setAttribute('aria-pressed',String(paused));
controls.autoRotate=!paused && !userInteracted? perhaps if manually paused resume auto rotate yes.
`controls.addEventListener('start',()=>{controls.autoRotate=false;});`
When reset `userInteracted=false; controls.autoRotate=!paused;`.
Paused scene still autoRotate off. Reset while paused camera sets initial but no rotate.
Button labels include icons unicode? Avoid unsupported. use "Pause motion", "Reset view".
On pointer down canvas perhaps help panel fades after interaction? Could add body class interacted and help opacity .6, not needed.
`renderer.domElement.addEventListener('pointerdown',()=>{document.body.classList.add('exploring')},{once:true});` CSS .exploring .hint perhaps. Not needed.
Make sure OrbitControls max target above ground. `controls.maxPolarAngle=Math.PI/2 - .08`; `controls.minPolarAngle=.2`; minDistance=6.5 maxDistance=34; enablePan=false. touch defaults rotate and dolly. `controls.zoomToCursor = false` property exists perhaps. Don't use uncertain. `controls.enableDamping=true; dampingFactor=.055; rotateSpeed=.45; zoomSpeed=.7; autoRotate=true; autoRotateSpeed=.22`.
Initial `controls.update()`.
Tone mapping and material colors. Fire point light intensity in r170: PointLight shadow update. `fireLight.shadow.focus` exists for spot not point no. only camera near/far. `fireLight.shadow.camera.near=.15; far=25`. Should updateProjectionMatrix after setting? PointLight shadow camera update handled perhaps set then `.updateProjectionMatrix()`.
Directional shadow camera type Orthographic, set bounds.
Light target after scene.add.
Set ambient no shadows.
Star generation positions upper hemisphere y>0.06, but moon at low position. Sky dome. Stars near horizon might be fog false and show through ground? Ground depth occludes below horizon; stars y positive. Fine.
Point stars behind trees and ground. Use size .28, bright .48.
Some stars below tree line hidden.
Can create star colors `new THREE.Color().setHSL(.58+rand*.08,.25,.65+rand*.3)`; PointsMaterial vertexColors true. Add `transparent:true, opacity:.85, blending:Additive, depthWrite:false, fog:false, sizeAttenuation:true`.
PointsMaterial `toneMapped:false` for brightness.
Moon material toneMapped false. Halo SpriteMaterial `color:'#b9d2ff'` map radial with white alpha; if canvas texture rgba includes color and material color multiplies. Set texture gradient white. create radial texture using rgba(255,255,255,...). color.
`moonHalo.scale.set(14,14,1);`
Moon craters? Could add few dark small circle meshes on sphere impossible orientation. Low poly moon simple. Perhaps use SphereGeometry(2.3,20,12) material flatShading true to fit low poly, but visible facets perhaps stylish. Add another smaller sphere behind? no.
Moon light separate.
Ground plane perhaps intersects sky at horizon with fog. Good.
No network beyond imports. Canvas textures inline.
Potential UI CSS `backdrop-filter` works Chrome, fine. body touch-action none? `canvas {touch-action:none;}` OrbitControls handles. body no scroll.
Resizing:
function onResize(){
const w=window.innerWidth,h=window.innerHeight;
camera.aspect=w/h;
// adapt fov smoothly based current width? if w<600 camera.fov=55 else50; but user view same; okay update.
camera.fov = w<600?55:50;
camera.updateProjectionMatrix();
renderer.setPixelRatio(Math.min(window.devicePixelRatio||1,2));
renderer.setSize(w,h,false); // canvas CSS width100%, height100%; false means no style update, initial setSize default sets style. use setSize(w,h) simpler.
sparks material uniform update.
}
Set initial camera fov via initial function then onResize perhaps. `renderer.setSize(w,h);`.
window add resize passive.
Screenshot first 3s: Everything visible. Flame animation phase random initial. Sparks initialized distributed with age, smoke visible. UI visible. AutoRotate.
Could include initial static sparks positions with age and updated in reset. Implement resetSpark correctly:
const sparkData = Array.from({length},()=>({vx:0,...}));
function resetSpark(i, initial=false){
const d=sparkData[i]; const angle=rand()*2π...
d.vx=(rand()-.5)*.45; ...
d.life=1.2+rand()*2.4;
d.age=initial?rand()*d.life:0;
d.seed=rand()*Math.PI*2;
const p=3i;
positions[p]=Math.cos(angle)*rad + d.vx*d.age;
positions[p+1]=.55+d.vy*d.age-.12*d.age*d.age;
positions[p+2]=...;
sizes[i]=.06+rand()*.1;
alphas[i]=...
const color = rand()>.45 ? new Color('#ffd783') ...; set colors.
}
Note at age near life positions perhaps y up to5, good. Some age close life but first update reset. 120 evenly spread after one frame.
Update:
for d age... if > life reset and continue (attributes arrays values changed).
positions[p]+= (d.vx+Math.sin(elapsed*1.7+d.seed)*.12)*dt;
positions[p+1]+= (d.vy-.22*d.age)*dt;
positions[p+2]+=(d.vz+Math.cos(...)*.1)*dt;
const n=d.age/d.life; alpha[i]=Math.min(1,n*10)*Math.pow(1-n,.7);
Set needsUpdate for position and alpha each frame. colors and sizes only on resets also mark each frame perhaps easier `geometry.attributes.aColor.needsUpdate=true; aSize` every frame but small. Set only update all each frame acceptable.
Spark shader gl_PointSize with perspective: `gl_PointSize = aSize * uPixelRatio * (280.0 / max(1.0,-mvPosition.z));` aSize .08, depth20 =>1.12*DPR. On DPR2 canvas actual pixels 2.24, CSS1.12. Too tiny; size .12-.25 =>2-3.5. Use aSize=.09+rand*.12, 360 factor: depth20 =>1.6-3.8 px. Good. Bright. `gl_PointSize=clamp(...,1.0,8.0*uPixelRatio)` perhaps min1 actual. no.
fragment soft disc with hot center: `float d=length...; float alpha=(1.0-smoothstep(.05,.5,d))*vAlpha; vec3 color=vColor*(1.0+smoothstep(.3,0.0,d)*.5);`. Additive.
Shader output include `<colorspace_fragment>` but Additive and tone mapping? Use `#include <tonemapping_fragment>` then colorspace. This may dim colors but okay. `material.toneMapped=false` if omit tone map. Better include only colorspace for proper output. For ShaderMaterial renderer might automatically add `#define STANDARD` no. `<colorspace_fragment>` exists r170. yes. Add `gl_FragColor = vec4(vColor,alpha); #include <colorspace_fragment>` valid line placement with preprocessor at new line.
Points renderOrder=6.
Flame MeshBasic toneMapped=false plus renderer output color space converts? Material toneMapped false still color space output yes. Good.
Glow CanvasTexture colorSpace SRGB, Sprite toneMapped false.
Smoke toneMapped true default.
Camera above ground guarantee if target fixed and polar bound. target y1.6, ground at camera perhaps <.6. maxPolar=1.49 gives min y at max dist 34 1.6+2.75=4.35. safe. min distance 6.5 y2.1. good.
No panning means no target movement.
At least 30 trees =64. UI says "64 PINES" generated exact. Could define TREE_COUNT=64 and update DOM count dynamically to avoid mismatch.
Trees random around clearing no on fire r>=10. Great.
Should perhaps show trees on first view not hidden by fog. fog near 20 far60, nearest trees at10 fully, distant fade. With camera at21 from origin, trees behind fire at z-10 distance camera~32 -> fog 40%, visible; trees near camera perhaps at10 from origin close to camera but view behind. Good. Forest surrounds.
One potential issue with shadow and transparent flames: PointLight is inside flames but flames castShadow default false. Logs cast. Ground point shadow outside stone ring may be mostly blocked by stones/logs creates nice.
Moon directional target no user.
Ground grass color perhaps too dark. Hemisphere .85.
Detailed color scheme:
background #04070d.
sky top #02040a horizon #16283a.
ground colors #162821,#1b3027,#22372b.
clearing #33291f,#403225.
pine colors ['#16352e','#1b4034','#234b3b','#2a513f'].
trunks ['#30241f','#3a2a22'].
moonlight #9eb9e8 intensity .8.
ambient #1c2c42 .55.
hemi sky #526d96 ground #15160f .55.
Fire lights strong.
UI accent #efb76a.
Could add CSS vignette `background: radial-gradient(circle at 50% 52%,transparent 35%,rgba(0,0,0,.2)75%,rgba(0,0,0,.48)110%);` plus `linear-gradient` top subtle. It may darken trees too much but aesthetic. pointer none z2; UI z3.
Grain effect without external image? CSS repeating radial impossible, avoid.
Header text shadows. Eyebrow dot pulse animation via CSS, if reduced motion disable CSS? Could honor prefers-reduced-motion by initial paused? Not required. Could include media reduce disables dot only. Scene motion needed.
Loading overlay perhaps small top status dot "LIVE". Good.
No title huge. h1 font Georgia? Low-poly outdoors could use system sans with letter spacing. Use `font-family: Inter, ui-sans-serif,...` but Inter external not loaded; system. h1 `font-weight:300; letter-spacing:-.04em`. "Emberwood" elegant. Eyebrow monospace.
Controls help card uses backdrop-filter blur12 background rgba(6,12,18,.42) border rgba white .1, radius14. Bottom buttons similar.
At 375 header top18 left18, subtitle max200, help hidden, bottom actions right12 etc. Footer stats hidden, footer justify center perhaps buttons. Add mobile hint separate visible.
HTML output exactly one code block. Should not include explanation.
I'll verify technical syntax in code thoroughly.
Script outline:
import ...
const container...
const scene...
const rng...
But initial camera view uses width.
Build environment functions then objects.
Order:
- renderer/camera/controls
- lights perhaps later.
- sky, stars, moon
- ground, clearing
- forest
- grass rocks logs
- campfire flames/sparks
- events animate.
Random `rand` defined before geometry.
`randomRange`.
`const tmpColor = new THREE.Color()` avoid mutate shared? For ground each vertex:
const c = new THREE.Color().copy(groundA).lerp(groundB, ...); creates ~1681 objects fine. Could reuse `const color=new THREE.Color()` inside loop one object each? set attribute copies numbers. use one outside.
Ground geometry indexed and vertex colors interpolation; flat shading with vertex colors still interpolated but facets normals. Fine.
`groundGeo.attributes.position.needsUpdate` not necessary before first render but computeVertexNormals uses.
`groundGeo.computeVertexNormals()`.
Clearing triangle vertices with per-face color:
const base=new THREE.Color('#342a20'), alt=...
for each face choose c; push c for all 3 vertices. compute normals.
`clearing.position.y=-0.045`? vertices y perhaps0. If ground at center -.12, yes. Ring perimeter ground height may still -.12 until r6, at r8 blend small giving perhaps-.08; clearing y-.045 above. Good.
Mesh no position needed use vertices y=0 and mesh.position.y=-.055.
Grass ground y.
Tree lower cones matrix scale:
unit ConeGeometry(1,1,7,1,false); geometry flatShading material handles normals from geometry smooth by default? ConeGeometry normals are computed smooth-ish around sides? `flatShading:true` material uses derivatives to face normal so faceted. Good.
Trunk CylinderGeometry(1,1,1,6,1) with radius same top/bottom. I could use CylinderGeometry(.75,1,1,6) so unit bottom1 top.75, scale radius. Nice.
Trees slight random layer offset in position x,z but keep all around.
const treeH etc.
`lower.setColorAt(i,color.set(palette[...]).offsetHSL(...))`; `Color.set` accepts string. `.offsetHSL` mutates and returns. Use separate tmp color reused. setColorAt copies.
For each layer color different scale lightness: lower darkest, mid same, upper lighter. `lowerColor.set(palette).multiplyScalar(.85)` in linear perhaps. upper multiply1.12. instance colors.
Materials base color 0xffffff. all roughness .92.
`for(const mesh of [trunks,lower,mid,upper]){mesh.castShadow=true;mesh.receiveShadow=true;mesh.instanceMatrix.needsUpdate=true;if(mesh.instanceColor)mesh.instanceColor.needsUpdate=true;scene.add(mesh);}`
No mesh count zero.
Trees exact while.
Grass mesh instance colors perhaps material color white. `grass.castShadow=false`.
Matrices rotation includes random tilt? quaternion.setFromEuler(new Euler((rand-.5)*.12,rand*6.28,(rand-.5)*.12)); each blade tilt. Cone bottom centered. position y ground+h*.45 to embed. scale(x,h,z) where unit cone height1, width radius1? ConeGeometry(.16,1,3) then scale x1 etc. Use geometry ConeGeometry(1,1,3); scale(.12+rand*.1,h,.12+...). good.
Stones:
const rockGeo=new THREE.DodecahedronGeometry(1,0);
const rockMats=[...].map MeshStandard flat.
function addRock(x,z,s,yOverride) { mesh=new Mesh(rockGeo, random mat); mesh.position.set(x,yOverride??ground+ s*.4,z); mesh.scale.set(s*(.8+...),s*(.5+...),s...); rotation random; cast receive; scene.add}
Fire ring y perhaps use `addRock(x,z,.45,.18)` but nonuniform sy .25 etc. `s` .42-.58, center y .2, extends .3, bottom -.1. good.
12 ring positions angle plus jitter .05, radius1.42+rand .12. Use deterministic.
Scattered 18 radius3.8-11, size .25-.85; skip near seat positions? Seats not yet defined. Could define seatData before scatter. I can create seat logs first then rocks no overlap perhaps.
Order seats before stones or ring. logs around.
makeLog uses bark materials two variants and end. `const mesh=new THREE.Mesh(geometry,[bark,end,end]);`
rotation: `mesh.rotation.set(0,angle,Math.PI/2);` default order XYZ. Should test orientation with Euler. Three Euler XYZ rotation matrix = Rz*Ry*Rx? Actually matrix makeRotationFromEuler for XYZ formula corresponds R=Rx*Ry*Rz? Applied to vector y: Rz first for XYZ? derive: x basis after Euler z 90 rotates y to -x, then y rotation rotates -x in xz. Matrix `R = Rx * Ry * Rz` gives Rz first -> right. Three XYZ likely R=Rx Ry Rz yes. With x=0 => Ry Rz. good. angle rotates. So length along XZ. Fine.
Could explicitly use quaternion: `const qZ=new Quaternion().setFromAxisAngle(zAxis,Math.PI/2); const qY=...; mesh.quaternion.copy(qY).multiply(qZ);` unambiguous. Simpler function:
mesh.rotation.z=Math.PI/2;
mesh.rotation.y=angle;
works.
For slight tilt log seat perhaps rotation.x .03, not needed.
Add log end rings perhaps material caps visible. CylinderGeometry cap uses same flat end. Good.
Fire logs `makeLog(2.5,.19,new Vector3(0,.22,0),0, true)` etc. But seats ground around -0.12 center y radius? define function takes position provided. For seat y=groundHeight+radius*.85 because cylinder circular radius extends world y? Rotated y axis to horizontal, radial vertical radius max .radius (bottom). center ground+radius*.9 embed. fire center y=.15+. etc.
Three fire logs at y=.2 angle0, y=.38 angleπ/3, y=.55 angle-π/3. Their cylinders intersect at center, looks stacked. radius .18, vertical spacing .2 equals diam .36 slight intersect. Fine. top log top .73, flame base .45 starts inside.
Stone ring center .17.
Ember bed y=.16, top log hides but glowing visible between.
Seat log positions not too close ring: (-4.6,2.8) dist5.4, (4.4,-3.0) dist5.3, (-3.4,-4.5)5.6. Good.
Stumps at (4.9,3.6) dist6.1 and (-6,-.5) etc. Add CylinderGeometry(.55,.65,.8,8), position y ground+.4; cast receive; materials [bark,end,end]. A stump counts logs perhaps. Add small top inner cylinder disk? material cap enough.
Stump near camera at(4.9,3.6) foreground right but fire at0; okay.
Scattered rocks avoid logs via seat centers array. For each candidate if distance to seat <1.6 skip. while count18 attempts.
Some large rocks around clearing. random scale.
Sky stars before ground render order. no issue.
Flames geometry material:
`const outerFlameMaterial = ...` but if share material across meshes and adjust opacity per mesh, cannot individual if shared. create per mesh material clone. Fine 7 materials.
`makeFlame(spec,index)` with geometry unique. material new MeshBasicMaterial({
color: spec.inner ? spec.color : 0xffffff,
vertexColors: !spec.inner,
transparent:true,
opacity:spec.opacity,
blending: spec.inner?THREE.NormalBlending:THREE.AdditiveBlending,
side:THREE.DoubleSide,
depthWrite:false,
toneMapped:false
});
If inner material color set but geometry has color and vertexColors false, good. Outer color white and vertex color gradient. Additive opacity .5.
`mesh.renderOrder=spec.inner?5:4;` sparks6.
`mesh.frustumCulled=false` perhaps geometry bounding spheres with scale dynamic but okay. no.
userData copy.
`flameGroup.add(mesh);` group at origin. flames positions world (base y).
Glow group perhaps scene.
Fire ring lights:
const fireLight = new THREE.PointLight(0xff7a2c,85,26,2); position0,2.2,0; castShadow...
Add secondary `const emberLight=new THREE.PointLight(0xffb14d,10,6,2)` position0,.8,0 no shadow perhaps enhances. Main warm. Requirement light casts shadows yes.
Flame light animated.
Ambient global.
`glowSprite` map; sprite material opacity .32. With Additive toneMapped false, can wash fire but nice. Scale4.2 perhaps.
Canvas texture radial:
const gradient=ctx.createRadialGradient(64,64,0,64,64,64);
grad.addColorStop(0,'rgba(255,255,255,0.85)'); .18 .45, .5 .1,1 0.
For fire glow material opacity .18 perhaps texture center .85 means .15 additive. good. scale5.
Smoke texture center .4, mid .18 etc. each opacity .1 yields faint. Good.
Ember materials and animate emissiveIntensity. `emissive:0xff3d08` accepted.
`const emberGeo=new THREE.IcosahedronGeometry(.12,0)` each scale random; material two shared.
Fire update emberMats forEach.
Smoke sprite positions y2+cycle*4.8; drift downwind +x. scale as `sprite.scale.setScalar(...)` but Sprite scale z should1; `scale.set(s,s,1)`.
`material.rotation` can rotate sprite random, animate? SpriteMaterial rotation property. Set per userData.
`material.opacity=Math.sin(p*Math.PI)*.075;`.
Since sprite map has alpha and opacity, faint.
`smokeSprites` initial p i/n means distributed. screenshot smoke subtle.
Stars animate no geometry update.
Fireflies perhaps I can add to make clearing alive. Requirements "sparks rising as simple particle system" already robust. Extra not needed.
UI dynamic:
`document.getElementById('tree-count').textContent = `${TREE_COUNT} PINES`;`
Pause icons text etc.
Footer:
<div class="footer-left"><span class=live-dot></span><span id=tree-count>64 PINES</span><span class=sep></span><span> NIGHTFALL</span></div>
Perhaps top already live. Fine.
Desktop bottom hint center absolute? Structure `.bottom-bar {position:absolute;left:28px;right:28px;bottom:24px;display:flex;justify-content:space-between;align-items:flex-end;}` includes `.orbit-hint` centered? Three sections. left stats, center hint, right actions. At 1280 fits. At mobile center hint positioned above actions. CSS:
.bottom-bar display:grid;grid-template-columns:1fr auto 1fr;
.stats justify-self:start; .hint; .actions justify-self:end.
At max-width700 stats display:none; grid-template-columns:1fr; justify-items:center; gap10; .actions justify-self:center; .hint order:-1? Could have buttons at bottom, hint above. Set bottom-bar display:flex flex-direction:column align center; .hint order0; .actions order1.
Header subtitle perhaps.
Controls help top-right.
`button` pointer events auto.
`ui` fixed inset0 pointer-events:none.
Canvas z0, vignette z1, ui z2.
Focus outline.
Could include "Scroll / pinch to zoom" adapts. Hint text use `<span class=desktop-hint>Drag to orbit · Scroll to zoom</span><span class=mobile-hint>Drag to orbit · Pinch to zoom</span>`.
Controls help has 3 rows perhaps keyboard not needed.
No external fonts.
First paint before module loaded body dark gradient with UI title. Good.
Should be cautious code size perhaps 1000 lines? I can produce ~600 lines. Desired complete. No need too verbose comments but enough structure. I can output ~35k chars.
I'll simulate key geometry positions and moon visibility. Camera controls target(0,1.5,0), pos(12,8.5,16). FOV50 perhaps set50. Moon(-45,14,-30) rel cam(-57,5.5,-46). View dir target-camera(-12,-7,-16) normalized. Compute angle between: dot= (-57*-12)+(5.5*-7)+(-46*-16)=684-38.5+736=1381.5; lengths moon sqrt3249+30+2116=73.46; view len21.93? sqrt144+49+256=449 sqrt21.19 actually 20^2=400+49=449=21.19. product1556; cos.887 angle27.5°, greater than vertical25 but combined diagonal could still inside if horizontal component. Should decompose vertical relative. Forward horizontal norm20, down angle atan7/20=19.3°, not17.5. Moon horiz73.46, alt4.29°, total23.6°, vertical within25. Horizontal forward bearing atan2(x,z) both negative: forward x/z=.75 angle from -z toward -x=36.87°, moon x/z=1.239 angle51.1°, diff14.2°. Camera projected vertical coordinate approx relative vertical after horizontal rotation slightly 23°, within25. horizontal half FOV at aspect1.6: hfov=2 atan(tan25*1.6)=73.4 half36.7. 14 fits. So moon near top-left visible. y screen coordinate normalized tan23/tan25=.91 near top. Halo extends beyond but sphere visible. Great. Auto rotate 3 sec speed.22 ~2.2°? Fine.
At mobile aspect375/800=.469, hfov~25°, horizontal half12.5°, moon horizontal14° might be offscreen! Should adjust mobile moon or camera? Same world moon but portrait horizontal narrow. To ensure moon visible mobile, on initial mobile choose camera position such that moon aligns horizontal or show title? Requirement moon should visible on mobile gracefully ideally. Could adjust moon position based viewport? Better set initial mobile camera angle more toward moon so moon horizontal offset lower while fire still center? Camera orbit target fire means view bearing fixed by camera pos. I can choose mobile camera position angle matching moon bearing while maintaining composition: camera pos (18,11,12) (bearing x/z1.5) vs moon bearing relative camera perhaps(-63,-42) ratio1.5 aligned. Desktop camera(12,16) ratio.75. So mobile initial camera (18,11,12), distance23, moon relative(-63,3,-42) bearing1.5 exactly forward bearing1.5, aligned. Vertical down atan9.5/21.6=23.7°, moon alt2.2° =>26° with fov55 half27.5 visible near top. Fire smaller but okay. Alternatively moon reposition to align both impossible unless close to forward bearings; could place moon x=-45,z=-60 ratio relative desktop57/76=.75 aligns desktop, mobile at18,12 relative63/72=.875 close mobile1.5 not. Better choose moon at position where horizontal directions from both camera views close: Moon far distance so camera offset negligible. If moon position direction x/z ~1 (world), desktop rel ratio~? M=(-60,-60), desktop rel(-72,-76)=.947, forward.75 diff4.5°, mobile pos(18,12) rel(-78,-72)=1.08 forward1.5 diff9°, within12.5. vertical: desktop horiz104.6 y5.5 alt3°, down19.3 total22.3 <25. mobile horiz106 y3 alt1.6 down23.7 total25.3<27.5. Great moon M(-60,14,-60), radius2.5, dist88 from desktop, size50 perhaps. Align desktop slight left, mobile slight right. Stars and moon visible both. Use moon(-55,16,-62)? desktop rel(-67,-78) ratio.859 vs.75 diff2.1°, mobile(-73,-74)=.986 vs1.5 diff10.2° within12.5; vertical desktop horiz103, alt4°, total23.3; mobile104, alt2.6 +23.7=26.3 within27.5. Good. Moon near top. If mobile FOV58 half29 more safe. Set mobile initial(18,11,12), fov56. Desktop(12,8.5,16) fov50. Moon(-58,17,-64): desktop rel(-70,8.5,-80) horiz106.3 alt4.6+19.3=23.9 within25 (near edge), mobile rel(-76,6,-76) horiz107.5 alt3.2+23.7=26.9 within28. horizontal desktop ratio.875 vs.75 diff2.3°, mobile1 vs1.5 diff11.3 within mobile hfov? vertical56 aspect.468 h half=atan(tan28*.468)=14°, fits. Great. Moon visible. Use radius2.5, halo18. Position distance~86, sphere apparent ~50px.
But moon light at(-58,17,-64) low altitude direction (17 height), directional angle low, long shadows nice. Could set light position(-30,45,-35) separate for illumination; moon at world low but light direction not exact okay. Use moonLight position(-25,35,-30) for better. Visual moon low due camera perspective.
Sky stars in portrait visible top sliver. Fine.
Alternatively set camera target higher on mobile to reduce downward angle, e.g target(0,2.2,0), down atan8.8/21.6=22.2 slight. Fire still visible lower. Set target same for consistency 1.5? initial mobile target2.0. Function setInitialView target y mobile2.0 desktop1.5. Controls target fixed thereafter. Moon relative vertical less. Fire at0.5 target2 gives -4°, fine. Desktop target1.6. Good.
Desktop down atan6.9/20=19°, moon alt4.6 total23.6. mobile down atan9/21.6=22.6 +3.2=25.8 <28. Great.
Camera max polar etc target y variable.
Initial `setInitialView` calls controls.update which autoRotate may rotate immediately tiny no.
On resize camera fov = aspect<.75 ? 56:50 but doesn't reposition. If rotate device after load, moon may move off due horizontal FOV; graceful doesn't guarantee moon always visible. okay.
Tree corridor initial based desktop view angle `Math.atan2(16,12)=.927` (a in xcos,zsin). Mobile corridor differs but trees r>=10 might block mobile initial at camera r21.6 and angle atan2(12,18)=.588. Could clear both corridors to ensure no trunk foreground: skip if r<19 and angle near .927 or .588 <.18. More clearing but 64 still. Also camera max orbit can get trees between view but natural.
Moon background tree occlusion at direction behind fire toward moon vector (-x,-z), angle atan2(z,x) both neg = atan2(-64,-58)=-2.307 rad or3.976. Trees at r10 along that direction height up to11 could occlude moon low apparent. Moon appears at top relative but world altitude11° from origin, trees height10 at r10 altitude45°, so yes trees between camera and moon potentially block, natural but moon could be fully behind tree. Camera line to moon crosses forest behind fire; at r10 from origin (distance camera ~30) line height? camera at8.5, moon14 over distance ~? at tree point closer to fire perhaps line height10, tree height10 so potentially. Many trees random; chance one in line low. Moon may peek through. To guarantee visibility, clear a narrow moon sightline corridor for trees on far side r10-22 around angle of moon direction from origin ~atan2(-64,-58)=3.976. But trees outside corridor still forest. I can skip trees with r<20 and angular diff to moonAngle<.12. This creates gap for moon. Good. Initial clearing also. Then moon visible.
Should not skip far trees r>20? line beyond r20 height moon line y? from camera to moon at far tree farther than fire? moon at86 distance; at r30 behind fire distance from camera50, line height11, trees11 could occlude. Clear corridor r<30 angle<.10 perhaps. But then visible alley. Small angular width .08 ~9° total, okay. Requirement random around clearing not on fire still. I can define `inCorridor(a,view,.18,r,19)` and moon `.10,r<30`. With 64 attempts enough. This intentional composition. Moon near top perhaps above trees if line height high enough at far. Fine.
Alternatively place moon higher and camera target higher to show above tree tops. Current moon world alt11°, trees will definitely dominate but gap works.
Could use moon sprite at (-40,30,-50) and wider camera fov/target y3 to show; but fire less centered. Current okay.
Ground fog and moon no fog.
Perhaps add distant tree silhouettes as larger forest. 64 enough. Trees r10-38.
Random rings create trees behind moon corridor but not exact.
Stars points may be hidden by sky dome if sky renders after? Sky ShaderMaterial opaque (transparent false) renderOrder=-100 and depthWrite false. Stars transparent rendered after, depth test no sky depth so visible. Moon opaque with default renderOrder0 drawn after sky and before stars. Halo transparent overlays. Good.
Point material fog false property accepted in constructor? `PointsMaterial` has fog property inherited Material yes. SpriteMaterial fog property perhaps accepted.
Canvas textures no network. Use `texture.minFilter=THREE.LinearFilter;` default mipmaps okay canvas 128. Set colorSpace.
`renderer.domElement.addEventListener('contextmenu', e=>e.preventDefault())` optional right click disabled pan anyway. Could include to avoid menu if right drag but enablePan false. Not needed.
Controls pointer on canvas only.
Perhaps adjust lights to expose low-poly color. ACES toneMappingExposure1.15. Flame MeshBasic toneMapped false not affected. Standard ground can appear moderate. `AmbientLight(0x32445f,.6)` plus `HemisphereLight(0x6d84a8,0x17150e,.85)` and moon 1.0. Night still dark. Point intensity110. Fog color. Fine.
FireLight intensity range 90-130. At distance3 10 units, strong. Seat logs lit. Trees nearest at10 intensity~1 plus ambient. good.
Shadow map point far28.
`fireLight.shadow.radius=3` with PCFSoftShadowMap doc radius no effect with PCFSoft? no error. okay.
Set `fireLight.shadow.bias=-0.002`; at point light shadow acne perhaps. normalBias .04.
Renderer setPixelRatio before setSize. On resize if DPR changed updates spark uniform.
`const maxPixelRatio = window.innerWidth<600?1.5:2` perhaps reduce mobile. function pixelRatio `Math.min(window.devicePixelRatio ||1, window.innerWidth<600?1.5:2)`. On desktop retina2. Fine.
Directional shadow map size mobile perhaps512? set initially based width but resize not adjust. Could fixed1024. Point512.
No stats external.
Could use `THREE.ColorManagement.enabled=true` default. no.
Potential gl_FragColor with ShaderMaterial and r170 colorspace chunk name correct: r154 uses `colorspace_fragment` replacing encodings. r170 yes. Sky shader `#include <tonemapping_fragment>` and `<colorspace_fragment>` accepted. Points shader include colorspace only. If toneMapped false no tone mapping chunk. `toneMapped:false` property on ShaderMaterial prevents define but I don't include tone mapping. okay.
Sky shader could set toneMapped false and only colorspace; consistent color. Use uniforms Color linear, color space converts. No ACES to keep deep dark. `toneMapped:false`, fragment colorspace only. Good.
Star PointsMaterial toneMapped false. Moon same. Night sky stable.
Fire flame MeshBasic toneMapped false. Ember standard tone mapped.
Sparks `gl_PointSize = aSize * uPixelRatio * (340.0 / -mvPosition.z);` if mvPosition.z positive behind camera division negative, clipped anyway. `max(1.0,-mvPosition.z)` safe.
Alpha attribute update. Should set `geometry.setAttribute('aAlpha',new THREE.BufferAttribute(alpha,1));`.
Set all usage dynamic: position.setUsage(DynamicDrawUsage), alpha too. aColor/aSize perhaps dynamic due resets, set dynamic.
`sparkGeometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(0,3,0),8)` or frustumCulled false. do latter.
Material uniform uPixelRatio.
On update, if paused skip but attributes initial computed reset. Before first animate, call resetSpark all initial and attributes needsUpdate? BufferAttributes initial arrays passed with values after set? I create arrays zeros, geometry attributes, then loop reset sets array values but before first render needsUpdate not necessary initial buffer upload happens on render reading current array, fine. But colors updates also before initial upload. Good.
When update after resets set needsUpdate all each frame.
Flame update initial t0 via animate at elapsed dt. fine.
Smoke initial positions default0 until first frame, animate immediate.
Glow created at position etc.
Star material opacity update.
Pause motion initial elapsed0, clock. RAF. If paused button event between frames, dt still clock.getDelta consumed so resume no jump. good.
AutoRotate controls update uses real dt? OrbitControls update() default independent clock internal? It uses performance now? autoRotate per update fixed angle; frame rate varying, okay.
`controls.update(dt)` in r170 accepts deltaTime optional; can pass dt to autoRotate consistent. OrbitControls update(deltaTime=null) supported. Use controls.update() safest. AutoRotate slow enough.
If paused controls.autoRotate false.
Add scene loaded class after first render to perhaps loading hidden no.
Could include CSS @keyframes pulse. Fine.
Now think User specifically asks single HTML with import map exactly. Should ensure no other scripts source. I use module import only map. Good.
No external files images fonts. Canvas texture generated.
One code block only.
I should provide no textual intro. Good.
I'll draft code carefully in the response, but before the response consider any hidden requirement game self-playing? Not game. No "click to start" needed. AutoRotate shows interactive scene. Perhaps instruction generic says "Everything that makes demo interesting visible within first seconds nothing hidden..." yes all visible.
I can add initial unobtrusive loading? no.
No horizontal scrollbar due body overflow hidden, UI widths controlled. On 375, top title plus help hidden, footer buttons 2 x perhaps 110 + gap8=228 fits. Header h1 etc.
`ui` padding? use positions. `.brand{max-width:calc(100vw - 40px)}`.
`.help-panel` right28 top28 width205 hidden max-width760.
Footer left20 right20 bottom18. `.hint` white-space:nowrap font11. Buttons white-space:nowrap. At max-width480 .actions button padding8 10 font10. Fine.
Canvas style `display:block;width:100%;height:100%;outline:none;`.
body height:100%; #scene position fixed; `overflow:hidden`.
Vignette `position:fixed;inset:0`.
No selection.
UI bottom stats perhaps tree count exact. Use text "64 TREES" not pines if some cones all pines. yes.
Header label "AN INTERACTIVE LOW-POLY ESCAPE".
Title Emberwood.
Subtitle "A small fire. A wide forest. Nothing on the agenda." Aesthetic.
Help panel title "LOOK AROUND" and row `<span>DRAG</span> Orbit the clearing` etc. I can use `<div><kbd>DRAG</kbd><span>Orbit</span></div>`.
Bottom center hint duplicative with help. Could simplify help panel top-right shows "NIGHT / 01" and control instructions, bottom only buttons and stats. Duplication okay but avoid clutter. Top-right card `DRAG TO ORBIT` `SCROLL / PINCH TO ZOOM`; bottom hint `The fire never sleeps.` no. I can have help card with mouse row, bottom center `SPACE pause · R reset` but keyboard after click. Could list and users click first anyway. Better bottom center "Drag to orbit · Scroll to zoom", top-right status "CLEARING 01 / NIGHT" plus small decorative coords? User values functionality, help obvious.
Perhaps top-right controls panel with three rows and bottom center empty. Footer grid 1fr auto 1fr with center `.hint` desktop shows keyboard optional. On mobile helpful. Keep.
Button pause accessible aria-pressed=false. Reset.
Animation CSS dot pulse continues even pause; pause motion refers scene only. Fine.
Fireflies not included, okay.
Potential trees instanced color flat shading: `new THREE.InstancedMesh(geo,mat,count)` bounding sphere computed lazily from instance matrices. setMatrixAt then render calculates. Shadows fine.
Ground grass instanced cone with matrices dynamic not.
Moon halo Sprite at same position as moon and renderOrder1. Transparent sprite drawn after moon and stars depending order. Center gradient can make moon hazy, nice.
Flame glow Sprite at fire center; Sprite transparent and depthTest true. It is at same position as flames, rendered renderOrder1 before flames. Smoke order2, outer4 etc. Sorting stable.
Log material arrays and shadows with array materials supported. Directional shadow uses one depth material for all groups. fine.
Stump cap visible. Add growth rings with thin `TorusGeometry` rotated xπ/2 on top perhaps unnecessary. Could add to stumps for detail. Low poly cap flat light enough.
Stone ring materials randomly brown grey. Dodeca flat.
Ground plane vertex height get same. Plane width100 and camera far perhaps 180; fog hides edges at58. Sky horizon ground blend. Fine.
Star radius96 and sky115, camera far190 enough at camera opposite side max distance34 star far130<190. Moon distance perhaps100<190.
Camera far220 safer.
Fog scene applies to tree materials and ground. `scene.fog=new THREE.Fog(0x09141c,20,62);` nearest tree behind fire distance camera30 has fog(30-20)/42=.24, visible. Far at60 full. Clearing at distance20 near fog just starts, no fog. Good.
At mobile camera24 fire distance24 fog ~.1 might fade fire? Fog applies flames? MeshBasicMaterial fog default true! Flames at distance24 would fog slightly, okay but I may set flame material fog:false so fire bright. Glow sprite fog false. Sparks shader no fog. Logs at fire distance24 slight fog could dull. Set fog near24 far65 instead to keep clearing crisp. Trees behind at35 fog.2. `new THREE.Fog(0x09141c,24,64)`. Ground clearing at21 no fog. nearest trees at behind distance31 .15. good. Far fade. Requirement distant trees fog yes.
Night fog color deep navy.
Lights helper no.
Trees placement count64, ring radius10-38. Fog beyond64 camera dist up to60. nice.
Moon visibility corridor moonAngle compute `Math.atan2(moonPosition.z,moonPosition.x)` =atan2(-64,-58) -2.306. Tree angle a uses x=cos,z=sin. good.
Angle difference function:
function angleDelta(a,b){return Math.atan2(Math.sin(a-b),Math.cos(a-b));}
Skip:
const inInitialView = r<20 && (abs(delta(a,desktopAngle))<.17 || abs(delta(a,mobileAngle))<.15);
const inMoonView = r<31 && abs(delta(a,moonAngle))<.075;
Could remove ~10% ring positions, still random. If tree ring behind moon perhaps moon halo visible through gap. good.
Minimum dist1.65. Height range6.2-11.5, lower radius H*.18-.23. nearest trees won't block fire except at r10. Forest dense enough 64.
Could add 8 birches? all conifers requirement trees only. Fine.
Shadows directional camera covers origin radius35; moon pos(-25,35,-30) and far100. tree shadows long perhaps. `moonLight.shadow.camera.left=-32 ... near=1 far=100`. actual light at distance50, far100. map 1024.
Point shadow `fireLight.shadow.camera.far=26` and fireLight.distance=27 matching.
Fire flames heights central2.6 from base.45 top3.05, light y1.9 inside. Good.
Glow at y1.35 scale5.4.
Sparks rise to6.5.
Smoke to7.
Perhaps logs in fire obscure inner flame from low camera? camera y8.5, view above, flames visible.
Ground clear radius7.8, trees start10 leaving gap grass ring. Rocks and logs on patch. Nice.
Add subtle circular warm light disk? Point light naturally.
Ember emissive intensity.
Flame geometry `ConeGeometry(radius,height,7,3,false)` has base cap, vertex color assignment `t` perhaps floating tiny negative due precision, clamp.
`geometry.translate` then `position.getY` works.
Base bend modifies x positions including base at t0 no. Tip bend all.
Different specs bend sign.
`mesh.scale.y` animate around1.0 no anchor issue geometry base y0 so scaling anchors base good. rotation.z around base at mesh origin, good.
Position y base `.44 + spec.y` perhaps spec.y default0. Logs top .73, flame base .45 inside.
`mesh.position.set(spec.x,.46,spec.z)` initial.
Inner flames base .5.
Material outer vertex colors and Additive blending opacity .55. `spec.color` not used outer.
Fire core normal blend opacity .92 depthWrite false means outer behind might show through, okay.
Smoke material opacity sin; set `depthTest:true` so behind flames etc.
Sparks shader depthTest true.
Glow sprite depthWrite false depthTest true, might be occluded by logs in front lower part; natural.
One concern overlay `vignette` z1 above canvas can interfere screenshot brightness, mild.
Response code likely ~850 lines with CSS. I can be concise but complete. I'll formulate now in the response eventually. Before responding perhaps consider test console errors:
- `THREE.MathUtils.smoothstep(r, 6, 13)` correct signature smoothstep(x,min,max), yes.
- `new THREE.BufferGeometry().setAttribute` returns geometry, I use separate.
- `new THREE.Color(palette[...]).offsetHSL` valid.
- `.setScalar` exists Vector3, but use scale.set.
- `CanvasTexture.colorSpace` property.
- `SpriteMaterial` constructor accepts `fog` and `toneMapped` via setValues inherited, yes Material.setValues warns if property undefined? SpriteMaterial has fog boolean, toneMapped inherited. good.
- PointsMaterial sizeAttenuation true.
- OrbitControls `autoRotateSpeed`.
- Key event `event.target instanceof HTMLButtonElement` works but target window? keydown target body or button, `instanceof` safe.
- onResize references sparkMaterial defined later; event only after initialization likely but function defined before? I'll register at end after sparkMaterial. fine.
- `resetView` invoked button after all.
- `controls.addEventListener('start',...)` will also fire on autoRotate? No start only input. good.
- Pause button pointerdown not canvas, no control event.
- When resetting controls with damping residual, `controls.reset()` could use saved state but saved initial before mobile? I implement manual and call controls.update; damping delta may remain from prior rotation causing slight drift. OrbitControls has `reset()` dispatch etc but saved state initial. Could call `controls.saveState()` after initial set and reset uses controls.reset. But responsive initial changes. Simpler manual set then controls.update; residual sphericalDelta decays, minor. I can call `controls.reset()` only if saveState each reset? Hmm.
Implement `setInitialView`:
const mobile=window.innerWidth<640;
camera.position... controls.target...
camera.fov...
controls.update();
No error.
Reset sets autoRotate = !paused. controls.enableDamping residual perhaps negligible. fine.
- On window resize I update camera.fov based width, could unexpectedly change after reset no issue.
- `renderer.setSize(width,height);` updates style canvas width px height px; CSS fixed parent. good.
- `sparkMaterial.uniforms.uPixelRatio.value = renderer.getPixelRatio();`.
- Initial onResize called after spark created and before setInitialView? I can call setInitialView then onResize. setInitialView sets fov, onResize same.
- initial camera constructed fov50 aspect window. controls before position? set camera pos initial after controls creation. okay.
- `controls.saveState` not needed.
- HTML element tree-count exists.
- No semicolon issues.
Shader:
```
vertexShader: `
attribute float aSize;
attribute float aAlpha;
attribute vec3 aColor;
uniform float uPixelRatio;
varying vec3 vColor;
varying float vAlpha;
void main() {
vColor=aColor; vAlpha=aAlpha;
vec4 mvPosition=modelViewMatrix*vec4(position,1.0);
gl_PointSize=clamp(aSize*uPixelRatio*(340.0/max(1.0,-mvPosition.z)),1.0,10.0);
gl_Position=projectionMatrix*mvPosition;
}`
```
Clamp max10 actual px not DPR scaled perhaps at retina max5css, okay. No errors.
Fragment:
```
float d=distance(gl_PointCoord,vec2(.5));
float alpha=(1.0-smoothstep(.08,.5,d))*vAlpha;
if(alpha<.015) discard;
gl_FragColor=vec4(vColor,alpha);
#include <colorspace_fragment>
```
Three ShaderChunk colorspace_fragment uses `gl_FragColor = linearToOutputTexel( gl_FragColor );` function defined via shader program. yes.
`transparent:true` ensures alpha.
Sky:
vertex varying vWorldPosition; `vec4 worldPosition = modelMatrix * vec4(position,1.0);`.
fragment uniform Colors; `float h=normalize(vWorldPosition).y; float f=smoothstep(-0.08,.72,h); vec3 color=mix(horizon,zenith,f); float band = ...` no need. Add subtle `color += vec3(.015,.02,.035)*pow(1.0-clamp(h,0,1),4)` could lighten horizon. Not needed.
`gl_FragColor=vec4(color,1.0); #include <colorspace_fragment>`.
Material depthWrite false side BackSide. fog false custom ignored.
Sky mesh `frustumCulled=false` because camera inside sphere and bounding sphere fine but disable safe.
Ground plane rendered after sky but opaque order renderOrder default0 >-100.
Stars positions color arrays.
Bright stars materials with same vertex colors? Could just one stars material with sizes same. To add bright stars second Points without colors or reuse geometry subset. Implement `makeStars(count,radius,size,opacity,minY)` returns Points with colors. Two sets. Count780 and55.
Use random distribution:
`const y=minY + rand()*(.96-minY); const az=rand()*2π; const horizontal=Math.sqrt(1-y*y); const r=95+rand()*8;` positions x=cos az*horizontal*r,y=y*r,z=sin az*horizontal*r.
Bright minY .15. radius same perhaps overlaps but okay.
PointsMaterial size size world .3 and .55. At distance100 size.3 yields 1.2px, good. Bright .65 2.6.
No custom star twinkle per star, material opacity.
Fog false, toneMapped false.
`scene.add(stars,brightStars)`.
Animate both.
Moon low-poly sphere `SphereGeometry(2.45,20,12)` smooth enough; flatShading true perhaps subtle facets. Material color #fff1c7.
Could add moon smaller dark disk offset to simulate crescent? Full moon requirement just moon, full easier. Full sphere uniformly bright appears circle.
Halo map.
Ground colors using `groundA.lerp` would mutate groundA if not copy. use `tmpColor.copy(groundA).lerp(groundB,n)`.
clearing same.
Rock scatter `while(placed<18 && attempts<200)` with `placed` increment. Some attempt near seats. Good.
Fire ring12 plus18 =30 rocks. low-poly.
Stumps:
function makeStump(x,z,radius,height) uses `CylinderGeometry(radius*.88,radius,height,8,1,false)` [bark,end,end], position ground+height/2, cast receive. Add.
Seat log groundHeight with center.
Store seatCenters includes stumps perhaps for rocks skip.
Trees added before logs so logs appear in front by depth anyway.
No actual ambient audio. fine.
Potential h1 line etc no overflow.
One generic instruction "Something meaningful visible on first paint screenshot about 3 sec without interaction" module network perhaps. UI alone meaningful but scene should load. Fine.
Could add loading text and hide on first frame but risk if network slow screenshot still loading; not needed.
I think response code ready. Should output only code block. I'll construct the response with comments sections, indentation. Keep ~700 lines perhaps okay. I have lots token but no need overdo.
I'll consider if importing Three and OrbitControls through import map exact URL yes.
`<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>` exact no whitespace? matches. Good.
Could use `document.getElementById('scene').appendChild` before UI overlay in DOM; UI already parsed because script at end body. good.
No external requests except imports. Canvas textures inline.
One detail shadows warm point light flickers: light intensity flicker but shadow shapes only light position flicker. yes. Cast shadows enabled on fireLight. No need flames themselves shadows.
Orbit limits maxPolar. yes.
At least30 random. yes.
Resizing. yes.
Perhaps `renderer.shadowMap.type=THREE.PCFSoftShadowMap` with point light radius property accepted.
Moon directional also shadows nice.
Potential physically correct PointLight intensity changed r170 but 95 strong. okay.
Animation clock uses `const dt=Math.min(clock.getDelta(),0.04); if(!paused) elapsed+=dt;` controls autoRotate not dt but fine.
Update only if !paused. However if paused, fireLight intensity remains last, sparks static; stars static, smoke static. controls still can orbit, good.
When no pause elapsed starts dt each frame.
`updateSparks(dt)` resets initial. good.
`updateFire` material opacity etc.
`updateSmoke`.
`renderer.render`.
Use `requestAnimationFrame(animate)` at beginning or end. At end ensures if error stops; no errors. Standard:
function animate(){requestAnimationFrame(animate); ...}
animate();
First call schedules next and renders immediate. clock.getDelta initial small.
On resize pixelRatio cap perhaps set every resize; renderer.setSize after pixel ratio. good.
Button `pauseButton.addEventListener('click', togglePause);`
Space `event.preventDefault()` avoids page scroll (overflow anyway).
R reset no prevent necessary.
When help hidden mobile, bottom hint visible via mobile spans.
Stats top? tree count.
I'll finalize.