← Mike Higgins Home
Custom Code Experiment •

Dragon 2: ThreeJS

Source Code View HTML / CSS / JavaScript
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Y Ddraig Goch — Three.js</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body { background: #000; overflow: hidden; }
  canvas { display: block; }
</style>
</head>
<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);

const scene  = new THREE.Scene();
scene.background = new THREE.Color(0x080000);
scene.fog = new THREE.FogExp2(0x0a0000, 0.035);

const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 200);
camera.position.set(0, 2, 18);
camera.lookAt(0, 1, 0);

window.addEventListener('resize', () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

const ambientLight = new THREE.AmbientLight(0x1a0000, 1.0);
scene.add(ambientLight);

const fireLight = new THREE.PointLight(0xff5500, 8, 14);
fireLight.position.set(-4.5, 1.2, 1.5);
fireLight.castShadow = true;
scene.add(fireLight);

const fillLight = new THREE.DirectionalLight(0xff2200, 0.4);
fillLight.position.set(2, 6, 4);
scene.add(fillLight);

const rimLight = new THREE.DirectionalLight(0x220000, 0.6);
rimLight.position.set(-4, 3, -3);
scene.add(rimLight);

function makeFlag() {
  const group = new THREE.Group();

  const redGeo = new THREE.PlaneGeometry(10, 4.5);
  const redMat = new THREE.MeshLambertMaterial({ color: 0xcc0010, side: THREE.DoubleSide });
  const redMesh = new THREE.Mesh(redGeo, redMat);
  redMesh.position.set(0, 1.125, 0);
  redMesh.receiveShadow = true;
  group.add(redMesh);

  const creamGeo = new THREE.PlaneGeometry(10, 4.5);
  const creamMat = new THREE.MeshLambertMaterial({ color: 0xede7cc, side: THREE.DoubleSide });
  const creamMesh = new THREE.Mesh(creamGeo, creamMat);
  creamMesh.position.set(0, -1.125, 0);
  creamMesh.receiveShadow = true;
  group.add(creamMesh);

  const borderMat = new THREE.MeshLambertMaterial({ color: 0x4a3000 });
  const bW = 10.25, bH = 9.1;
  const borders = [
    { s:[bW,0.08,0.08], p:[0, 4.55, 0.01]  },
    { s:[bW,0.08,0.08], p:[0,-4.55, 0.01]  },
    { s:[0.08,bH,0.08], p:[-5.125, 0, 0.01] },
    { s:[0.08,bH,0.08], p:[ 5.125, 0, 0.01] },
  ];
  borders.forEach(b => {
    const m = new THREE.Mesh(new THREE.BoxGeometry(...b.s), borderMat);
    m.position.set(...b.p);
    group.add(m);
  });

  group.position.set(0, 0, 0);
  return group;
}
const flagGroup = makeFlag();
scene.add(flagGroup);

const dragonMat = new THREE.MeshPhongMaterial({
  color:    0xdd0018,
  emissive: 0x220000,
  specular: 0x440000,
  shininess: 30,
  side: THREE.DoubleSide,
});
const darkMat = new THREE.MeshPhongMaterial({
  color:    0x880010,
  emissive: 0x100000,
  specular: 0x220000,
  shininess: 15,
  side: THREE.DoubleSide,
});
const wingMat = new THREE.MeshPhongMaterial({
  color:    0xaa0014,
  emissive: 0x150000,
  specular: 0x330000,
  shininess: 10,
  transparent: true,
  opacity: 0.88,
  side: THREE.DoubleSide,
});

function extrudeShape(points, depth) {
  const shape = new THREE.Shape();
  shape.moveTo(points[0][0], points[0][1]);
  for (let i = 1; i < points.length; i++) shape.lineTo(points[i][0], points[i][1]);
  shape.closePath();
  return new THREE.ExtrudeGeometry(shape, {
    depth,
    bevelEnabled: true,
    bevelThickness: 0.04,
    bevelSize: 0.04,
    bevelSegments: 2,
  });
}

function makeEllipsoid(rx, ry, rz) {
  const g = new THREE.SphereGeometry(1, 24, 16);
  g.scale(rx, ry, rz);
  return g;
}

const dragon = new THREE.Group();

const bodyGeo = makeEllipsoid(2.2, 1.5, 0.9);
const body    = new THREE.Mesh(bodyGeo, dragonMat);
body.position.set(0.2, 0, 0.1);
body.castShadow = true;
dragon.add(body);

const neckPts = [[-0.45,0],[0.45,0],[0.35,2.2],[-0.25,2.2]];
const neckGeo = extrudeShape(neckPts, 0.55);
const neck    = new THREE.Mesh(neckGeo, dragonMat);
neck.position.set(-1.35, 0.5, -0.2);
neck.rotation.z = 0.35;
neck.castShadow = true;
dragon.add(neck);

const headGeo = makeEllipsoid(1.15, 0.75, 0.65);
const head    = new THREE.Mesh(headGeo, dragonMat);
head.position.set(-2.8, 1.65, 0);
head.castShadow = true;
dragon.add(head);

const snoutGeo = makeEllipsoid(0.9, 0.42, 0.38);
const snout    = new THREE.Mesh(snoutGeo, dragonMat);
snout.position.set(-3.65, 1.35, 0);
snout.castShadow = true;
dragon.add(snout);

const jawGeo = makeEllipsoid(0.82, 0.28, 0.3);
const jaw    = new THREE.Mesh(jawGeo, dragonMat);
jaw.position.set(-3.6, 1.06, 0);
jaw.rotation.z = 0.18;
dragon.add(jaw);

function makeHorn(rx, ry, rz, px, py, pz, rotZ) {
  const g = new THREE.ConeGeometry(0.12, 0.9, 8);
  const m = new THREE.Mesh(g, darkMat);
  m.scale.set(rx, ry, rz);
  m.position.set(px, py, pz);
  m.rotation.z = rotZ;
  return m;
}
dragon.add(makeHorn(1,1,0.6, -2.4, 2.62, 0.1,  0.3));
dragon.add(makeHorn(1,1,0.6, -2.7, 2.55, 0.1, -0.1));
dragon.add(makeHorn(1,1,0.6, -2.2, 2.48,-0.2,  0.5));

const eyeGeo  = new THREE.SphereGeometry(0.16, 12, 12);
const eyeMat  = new THREE.MeshPhongMaterial({ color: 0xff6600, emissive: 0xff3300, shininess: 80 });
const eye     = new THREE.Mesh(eyeGeo, eyeMat);
eye.position.set(-2.58, 1.88, 0.54);
dragon.add(eye);

const pupilGeo = new THREE.CylinderGeometry(0.03, 0.03, 0.28, 8);
const pupilMat = new THREE.MeshBasicMaterial({ color: 0x000000 });
const pupil    = new THREE.Mesh(pupilGeo, pupilMat);
pupil.rotation.x = Math.PI / 2;
pupil.position.set(-2.58, 1.88, 0.7);
dragon.add(pupil);

const toothMat = new THREE.MeshPhongMaterial({ color: 0xf0e8cc, shininess: 60 });
[-0.2, 0, 0.2].forEach((z, i) => {
  const g = new THREE.ConeGeometry(0.06, 0.28, 6);
  const m = new THREE.Mesh(g, toothMat);
  m.position.set(-4.32, 1.06, z);
  m.rotation.z = Math.PI;
  dragon.add(m);
});

const wingPts = [
  [0,0],[0.3,0.5],[2.5,2.8],[4,1.8],[3.2,1.2],[3.8,0.5],
  [3.0,0.2],[3.4,-0.4],[2.4,0],[1.8,-0.6],[0.5,-0.3]
];
const wingGeo  = extrudeShape(wingPts, 0.04);
const wingMesh = new THREE.Mesh(wingGeo, wingMat);
wingMesh.position.set(-0.8, 0.5, 0.45);
wingMesh.rotation.y = -0.15;
wingMesh.castShadow = true;
dragon.add(wingMesh);

const spineMat = new THREE.MeshPhongMaterial({ color: 0x770010 });
[[0,0,0, 2.5,2.8,0],[0,0,0, 4,1.8,0],[0,0,0, 3.8,0.5,0],[0,0,0, 3.4,-0.4,0]].forEach(([x1,y1,z1,x2,y2,z2]) => {
  const dir  = new THREE.Vector3(x2-x1, y2-y1, z2-z1);
  const len  = dir.length();
  const mid  = new THREE.Vector3((x1+x2)/2, (y1+y2)/2, (z1+z2)/2);
  const cyl  = new THREE.CylinderGeometry(0.04, 0.02, len, 6);
  const mesh = new THREE.Mesh(cyl, spineMat);
  mesh.position.set(mid.x - 0.8, mid.y + 0.5, mid.z + 0.55);
  mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0), dir.normalize());
  dragon.add(mesh);
});

const tailGroup = new THREE.Group();
const tailPts = [
  [-0.4,0],[0.4,0],[2.0,0.4],[3.4,0.1],[4.2,-0.5],
  [4.0,-1.0],[3.2,-0.7],[2.8,-0.4],[1.8,0],[0.1,-0.35]
];
const tailGeo  = extrudeShape(tailPts, 0.45);
const tailMesh = new THREE.Mesh(tailGeo, dragonMat);
tailMesh.position.set(1.2, -0.4, -0.2);
tailMesh.castShadow = true;
tailGroup.add(tailMesh);

[1.8,2.6,3.4].forEach((tx, i) => {
  const g = new THREE.ConeGeometry(0.08, 0.45, 6);
  const m = new THREE.Mesh(g, darkMat);
  m.position.set(tx + 1.2, 0.4 - i * 0.12, -0.2);
  m.rotation.z = -0.3 + i * 0.15;
  tailGroup.add(m);
});
dragon.add(tailGroup);

function makeLeg(px, py, pz, rx, ry) {
  const g = new THREE.Group();
  const upper = new THREE.Mesh(new THREE.CylinderGeometry(0.22,0.16,1.0,10), dragonMat);
  upper.position.set(0, -0.5, 0);
  upper.rotation.z = rx; upper.rotation.x = ry;
  g.add(upper);
  const lower = new THREE.Mesh(new THREE.CylinderGeometry(0.15,0.1,0.9,10), dragonMat);
  lower.position.set(Math.sin(rx)*0.9, -1.15, 0);
  lower.rotation.z = rx * 0.5;
  g.add(lower);
  [-0.12, 0, 0.12].forEach(cz => {
    const c = new THREE.Mesh(new THREE.ConeGeometry(0.06, 0.28, 6), darkMat);
    c.position.set(Math.sin(rx)*0.9, -1.72, cz);
    c.rotation.z = Math.PI + 0.2;
    g.add(c);
  });
  g.position.set(px, py, pz);
  return g;
}
dragon.add(makeLeg(-0.8, -0.9, 0.7,  0.5, 0));
dragon.add(makeLeg( 1.2, -1.0, 0.7, -0.3, 0));

[-1.2, -0.4, 0.4, 1.2].forEach((bx, i) => {
  const g = new THREE.ConeGeometry(0.07, 0.55 - i*0.05, 6);
  const m = new THREE.Mesh(g, darkMat);
  m.position.set(bx, 1.5 + i * 0.04, 0);
  dragon.add(m);
});

for (let i = 0; i < 5; i++) {
  const g = new THREE.SphereGeometry(0.22, 8, 6);
  g.scale(1, 0.35, 1);
  const m = new THREE.Mesh(g, new THREE.MeshPhongMaterial({ color: 0xbb0018, emissive: 0x0a0000 }));
  m.position.set(-0.6 + i * 0.45, -0.9 + Math.abs(i-2)*0.1, 0.88);
  dragon.add(m);
}

dragon.position.set(0.5, 0.5, 0.5);
scene.add(dragon);

const FLAME_COUNT  = 1200;
const SPARK_COUNT  = 220;
const SMOKE_COUNT  = 160;

function makeSprite(stops) {
  const c = document.createElement('canvas');
  c.width = 128; c.height = 128;
  const cx = c.getContext('2d');
  const grd = cx.createRadialGradient(64,64,0, 64,64,64);
  stops.forEach(s => grd.addColorStop(s.pos, s.color));
  cx.fillStyle = grd;
  cx.fillRect(0,0,128,128);
  return new THREE.CanvasTexture(c);
}

const flameTex = makeSprite([
  {pos:0.00, color:'rgba(255,255,220,1)'},
  {pos:0.20, color:'rgba(255,180,20,0.85)'},
  {pos:0.50, color:'rgba(255,60,0,0.5)'},
  {pos:0.80, color:'rgba(180,0,0,0.15)'},
  {pos:1.00, color:'rgba(0,0,0,0)'},
]);
const sparkTex = makeSprite([
  {pos:0.00, color:'rgba(255,255,180,1)'},
  {pos:0.30, color:'rgba(255,140,0,0.7)'},
  {pos:1.00, color:'rgba(0,0,0,0)'},
]);
const smokeTex = makeSprite([
  {pos:0.00, color:'rgba(50,15,10,0.55)'},
  {pos:0.50, color:'rgba(25,5,5,0.2)'},
  {pos:1.00, color:'rgba(0,0,0,0)'},
]);
const emberTex = makeSprite([
  {pos:0.00, color:'rgba(255,200,80,1)'},
  {pos:0.40, color:'rgba(255,80,0,0.6)'},
  {pos:1.00, color:'rgba(0,0,0,0)'},
]);

function makeParticleSystem(count, tex) {
  const positions = new Float32Array(count * 3);
  const colors    = new Float32Array(count * 4);
  const sizes     = new Float32Array(count);

  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  geo.setAttribute('color',    new THREE.BufferAttribute(colors, 4));
  geo.setAttribute('size',     new THREE.BufferAttribute(sizes, 1));

  const mat = new THREE.ShaderMaterial({
    uniforms: { tex: { value: tex } },
    vertexShader: `
      attribute float size;
      attribute vec4  color;
      varying   vec4  vColor;
      void main(){
        vColor = color;
        vec4 mv = modelViewMatrix * vec4(position,1.0);
        gl_PointSize = size * (380.0 / -mv.z);
        gl_Position  = projectionMatrix * mv;
      }
    `,
    fragmentShader: `
      uniform sampler2D tex;
      varying vec4 vColor;
      void main(){
        vec4 t = texture2D(tex, gl_PointCoord);
        gl_FragColor = vec4(vColor.rgb, vColor.a * t.a);
        if(gl_FragColor.a < 0.005) discard;
      }
    `,
    transparent: true,
    depthWrite:  false,
    blending:    THREE.AdditiveBlending,
  });

  const points = new THREE.Points(geo, mat);
  points.frustumCulled = false;
  return { points, geo, positions, colors, sizes };
}

const flames = makeParticleSystem(FLAME_COUNT, flameTex);
const sparks = makeParticleSystem(SPARK_COUNT, sparkTex);
const smokes = makeParticleSystem(SMOKE_COUNT, smokeTex);
const embers = makeParticleSystem(SPARK_COUNT, emberTex);

scene.add(smokes.points);
scene.add(flames.points);
scene.add(sparks.points);
scene.add(embers.points);

const MOUTH_LOCAL = new THREE.Vector3(-4.55, 1.2, 0.0);
function getMouthWorld() {
  return MOUTH_LOCAL.clone().applyMatrix4(dragon.matrixWorld);
}

function noise1(x) { return Math.sin(x * 1.7) * 0.5 + Math.sin(x * 4.3 + 1.2) * 0.3 + Math.sin(x * 9.7 + 2.8) * 0.2; }
function noise2(x) { return Math.sin(x * 2.3 + 0.5) * 0.5 + Math.sin(x * 5.9 + 3.1) * 0.3 + Math.sin(x * 11.3 + 0.9) * 0.2; }

function spawnParticle(role, randomLife) {
  const m = getMouthWorld();

  const ox = m.x + (Math.random()-0.5)*0.18;
  const oy = m.y + (Math.random()-0.5)*0.14;
  const oz = m.z + (Math.random()-0.5)*0.18;

  let vx, vy, vz, maxLife, size, drag, riseRate;

  if (role === 'jet') {
    const speed  = 3.5 + Math.random() * 4.5;
    const hSpread = 0.18 + Math.random() * 0.12;
    const vSpread = 0.14 + Math.random() * 0.08;
    vx = -speed;
    vy = (Math.random()-0.5) * speed * vSpread;
    vz = (Math.random()-0.5) * speed * hSpread;
    maxLife  = 0.55 + Math.random() * 0.55;
    size     = 0.22 + Math.random() * 0.28;
    drag     = 0.18;
    riseRate = 0.25;

  } else if (role === 'bloom') {
    const speed  = 1.2 + Math.random() * 2.2;
    const hSpread = 0.45 + Math.random() * 0.35;
    const vSpread = 0.40 + Math.random() * 0.30;
    vx = -(speed * (0.5 + Math.random() * 0.5));
    vy = (Math.random()-0.5) * speed * vSpread * 1.4;
    vz = (Math.random()-0.5) * speed * hSpread * 1.8;
    maxLife  = 0.9 + Math.random() * 0.9;
    size     = 0.45 + Math.random() * 0.7;
    drag     = 0.28;
    riseRate = 0.55;

  } else if (role === 'spark') {
    const speed  = 5 + Math.random() * 7;
    const angle  = Math.PI + (Math.random()-0.5) * 0.7;
    vx = Math.cos(angle) * speed;
    vy = (Math.random() * 0.6 - 0.1) * speed;
    vz = (Math.random()-0.5) * speed * 0.55;
    maxLife  = 0.4 + Math.random() * 0.7;
    size     = 0.06 + Math.random() * 0.09;
    drag     = 0.12;
    riseRate = -0.4;

  } else if (role === 'ember') {
    const speed = 0.6 + Math.random() * 1.4;
    vx = -speed * (0.3 + Math.random() * 0.7);
    vy = (Math.random() * 0.8 - 0.1) * speed;
    vz = (Math.random()-0.5) * speed * 1.2;
    maxLife  = 1.4 + Math.random() * 1.6;
    size     = 0.04 + Math.random() * 0.07;
    drag     = 0.05;
    riseRate = 0.1;

  } else {
    const speed = 0.3 + Math.random() * 0.5;
    vx = -speed * (0.2 + Math.random() * 0.4);
    vy = (Math.random()-0.5) * 0.3;
    vz = (Math.random()-0.5) * 0.5;
    maxLife  = 1.8 + Math.random() * 2.2;
    size     = 0.6 + Math.random() * 1.1;
    drag     = 0.55;
    riseRate = 0.55;
  }

  return {
    x: ox, y: oy, z: oz,
    vx, vy, vz,
    life: randomLife ? Math.random() * maxLife : 0,
    maxLife, size, drag, riseRate,
    noiseOffset: Math.random() * 100,
    noiseScale:  0.8 + Math.random() * 1.6,
    role,
  };
}

const flameState = Array.from({length: FLAME_COUNT}, (_, i) =>
  spawnParticle(i < FLAME_COUNT * 0.6 ? 'jet' : 'bloom', true));

const sparkState = Array.from({length: SPARK_COUNT}, () => spawnParticle('spark', true));
const smokeState = Array.from({length: SMOKE_COUNT}, () => spawnParticle('smoke', true));
const emberState = Array.from({length: SPARK_COUNT}, () => spawnParticle('ember', true));

function flameRGB(t, isBloom) {
  const boost = isBloom ? 0.08 : 0;
  const tt = Math.min(1, t + boost);
  if (tt < 0.10) return [1.00, 1.00, 0.92];
  if (tt < 0.28) { const s=(tt-0.10)/0.18; return [1.00, 1.0-s*0.45, 0.0]; }
  if (tt < 0.58) { const s=(tt-0.28)/0.30; return [1.00, (1-s)*0.55, 0.0]; }
  if (tt < 0.82) { const s=(tt-0.58)/0.24; return [1.0-s*0.3, 0.0, 0.0]; }
                 { const s=(tt-0.82)/0.18; return [0.7-s*0.5, 0.0, 0.0]; }
}
function flameAlpha(t) {
  if (t < 0.06) return t / 0.06;
  if (t < 0.55) return 1.0;
  return 1.0 - (t - 0.55) / 0.45;
}

function updatePool(stateArr, sys, dt, elapsed) {
  const pos = sys.positions;
  const col = sys.colors;
  const sz  = sys.sizes;

  for (let i = 0; i < stateArr.length; i++) {
    let p = stateArr[i];
    p.life += dt;
    if (p.life >= p.maxLife) {
      stateArr[i] = spawnParticle(p.role, false);
      p = stateArr[i];
    }

    const t   = p.life / p.maxLife;
    const nT  = elapsed * p.noiseScale + p.noiseOffset;

    const turbY = noise1(nT) * 0.55 * (1 + t * 2.5);
    const turbZ = noise2(nT + 5.3) * 0.55 * (1 + t * 2.0);

    const dragFac = 1 - p.drag * dt;
    p.vx *= dragFac;

    const fanFactor = t * 0.4;
    p.vy += turbY * dt * (1 + fanFactor * 3);
    p.vz += turbZ * dt * (1 + fanFactor * 3);

    p.vy += p.riseRate * dt;

    p.x += p.vx * dt;
    p.y += p.vy * dt;
    p.z += p.vz * dt;

    const i3 = i*3, i4 = i*4;
    pos[i3] = p.x; pos[i3+1] = p.y; pos[i3+2] = p.z;

    const role = p.role;

    if (role === 'smoke') {
      const a = t < 0.15 ? t/0.15 : t > 0.65 ? 1-(t-0.65)/0.35 : 1.0;
      col[i4]=0.07; col[i4+1]=0.02; col[i4+2]=0.01; col[i4+3] = a * 0.28;
      sz[i] = p.size * (1 + t * 3.5);

    } else if (role === 'spark') {
      const a = t < 0.12 ? t/0.12 : Math.max(0, 1-t);
      const [r,g,b] = t < 0.25 ? [1,1,0.65] : t < 0.55 ? [1,0.55,0] : [0.75,0.05,0];
      col[i4]=r; col[i4+1]=g; col[i4+2]=b; col[i4+3]=Math.max(0,a);
      sz[i] = p.size * (1 - t * 0.4);

    } else if (role === 'ember') {
      const a = t < 0.1 ? t/0.1 : t > 0.7 ? 1-(t-0.7)/0.3 : 1.0;
      const pulse = 0.7 + 0.3 * Math.sin(elapsed * 8 + p.noiseOffset);
      col[i4]=1; col[i4+1]=0.55*pulse; col[i4+2]=0; col[i4+3]=a*pulse*0.9;
      sz[i] = p.size * (1 + 0.2 * Math.sin(elapsed * 12 + p.noiseOffset));

    } else {
      const isBloom = role === 'bloom';
      const a = flameAlpha(t) * (isBloom ? 0.70 : 0.88);
      const [r,g,b] = flameRGB(t, isBloom);
      col[i4]=r; col[i4+1]=g; col[i4+2]=b; col[i4+3]=Math.max(0,a);
      const growFactor = isBloom ? (1 + t * 3.5) : (1 + t * 1.2);
      sz[i] = p.size * growFactor;
    }
  }

  sys.geo.attributes.position.needsUpdate = true;
  sys.geo.attributes.color.needsUpdate    = true;
  sys.geo.attributes.size.needsUpdate     = true;
}

let lastTime = performance.now();
const clock  = new THREE.Clock();

function animate() {
  requestAnimationFrame(animate);

  const now = performance.now();
  const dt  = Math.min((now - lastTime) / 1000, 0.05);
  lastTime  = now;
  const t   = clock.getElapsedTime();

  dragon.position.y = 0.5 + Math.sin(t * 1.1) * 0.08;
  dragon.rotation.z = Math.sin(t * 0.9) * 0.018;

  tailGroup.rotation.y = Math.sin(t * 1.4) * 0.22;

  wingMesh.rotation.z = Math.sin(t * 1.1) * 0.08;
  wingMesh.rotation.x = Math.sin(t * 0.9) * 0.05;

  const eyeFlicker = 0.7 + Math.random() * 0.3;
  eyeMat.emissiveIntensity = eyeFlicker;

  updatePool(flameState, flames, dt, t);
  updatePool(sparkState, sparks, dt, t);
  updatePool(smokeState, smokes, dt, t);
  updatePool(emberState, embers, dt, t);

  fireLight.intensity = 6 + Math.sin(t * 17) * 1.5 + Math.sin(t * 31) * 1.0 + Math.random() * 1.5;
  fireLight.color.setHSL(0.05 + Math.random() * 0.02, 1, 0.5 + Math.random() * 0.08);

  const mw = getMouthWorld();
  fireLight.position.lerp(new THREE.Vector3(mw.x - 0.5, mw.y + 0.1, mw.z + 0.5), 0.1);

  const pulse = 0.06 + Math.abs(Math.sin(t * 8)) * 0.05 + Math.random() * 0.03;
  scene.background.setRGB(pulse * 0.6, 0, 0);

  renderer.render(scene, camera);
}

animate();
</script>
</body>
</html>