SOURCE / PINNED RELEASE
Made of little things.
Poly Dogfight
- Release
- b8511ee374e3…
- Author-recorded commit
- 5579434b9d8b…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.napplet.soy%2F/n-44f63e5422b
Archive hash verified: bcadd2430d8ea2bc…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import {
CircleGeometry,
Color,
Euler,
Group,
IcosahedronGeometry,
InstancedBufferAttribute,
InstancedMesh,
Matrix4,
Mesh,
MeshBasicMaterial,
MeshLambertMaterial,
OctahedronGeometry,
Quaternion,
TetrahedronGeometry,
Vector3,
type BufferGeometry,
type Material,
} from 'three';
import { GRAVITY } from '../sim/constants.ts';
import { PALETTE as P } from './palette.ts';
type Particle = {
position: Vector3;
velocity: Vector3;
/** Euler angles for debris and sparks. */
rotation: Vector3;
spin: Vector3;
color: number;
life: number;
maxLife: number;
size: number;
};
/** Fading crash or rocket smoke: needs its own opacity, so it keeps a mesh. */
type Smoke = { mesh: Mesh<BufferGeometry, MeshLambertMaterial>; velocity: Vector3; life: number; maxLife: number; size: number };
const MAX_PUFFS = 260;
const MAX_DEBRIS = 320;
const MAX_SPARKS = 160;
/**
* Many short-lived copies of one shape drawn as a single instanced mesh,
* so a sky full of smoke is still one draw call per view.
*/
function createPool(geometry: BufferGeometry, material: Material, capacity: number) {
const mesh = new InstancedMesh(geometry, material, capacity);
mesh.instanceColor = new InstancedBufferAttribute(new Float32Array(capacity * 3), 3);
// Instances move every frame, so a cached bounding sphere would be wrong.
mesh.frustumCulled = false;
mesh.count = 0;
mesh.visible = false;
const live: Particle[] = [];
const spare: Particle[] = [];
const matrix = new Matrix4();
const turn = new Quaternion();
const euler = new Euler();
const scale = new Vector3();
const color = new Color();
return {
mesh,
live,
/** A fresh particle at `position`, or null when the pool is full. */
spawn(position: Vector3, color: number, maxLife: number, size: number): Particle | null {
if (live.length >= capacity) return null;
const p = spare.pop() ?? {
position: new Vector3(),
velocity: new Vector3(),
rotation: new Vector3(),
spin: new Vector3(),
color: 0,
life: 0,
maxLife: 1,
size: 1,
};
p.position.copy(position);
p.velocity.set(0, 0, 0);
p.rotation.set(0, 0, 0);
p.spin.set(0, 0, 0);
p.color = color;
p.life = 0;
p.maxLife = maxLife;
p.size = size;
live.push(p);
return p;
},
/** Age everything by dt; `step` moves a live particle and returns its scale. */
update(dt: number, step: (p: Particle, t: number) => number) {
let n = 0;
for (let i = 0; i < live.length; i++) {
const p = live[i]!;
p.life += dt;
const t = p.life / p.maxLife;
if (t >= 1) {
spare.push(p);
continue;
}
const s = step(p, t);
turn.setFromEuler(euler.set(p.rotation.x, p.rotation.y, p.rotation.z));
matrix.compose(p.position, turn, scale.setScalar(s));
mesh.setMatrixAt(n, matrix);
mesh.setColorAt(n, color.setHex(p.color));
live[n++] = p;
}
live.length = n;
mesh.count = n;
mesh.visible = n > 0;
mesh.instanceMatrix.needsUpdate = true;
mesh.instanceColor!.needsUpdate = true;
},
};
}
const randomUnit = (out: Vector3) => out.set(Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5).normalize();
/** Crash debris, smoke puffs and a ground shadow blob under the plane. */
export function createEffects() {
const group = new Group();
const smokeGeometry = new IcosahedronGeometry(1, 0);
const debris = createPool(new TetrahedronGeometry(0.6, 0), new MeshLambertMaterial({ flatShading: true }), MAX_DEBRIS);
const puffs = createPool(
smokeGeometry,
new MeshLambertMaterial({ flatShading: true, transparent: true, opacity: 0.5, depthWrite: false }),
MAX_PUFFS,
);
const sparks = createPool(new OctahedronGeometry(0.22, 0), new MeshBasicMaterial(), MAX_SPARKS);
group.add(debris.mesh, puffs.mesh, sparks.mesh);
const puffColors = [0xe4e0d8, 0x8a857f, 0x2e2b29];
const scratch = new Vector3();
const offset = new Vector3();
const radial = new Vector3();
// Crash and rocket smoke fade individually; recycle their meshes and materials.
const smoke: Smoke[] = [];
const spareSmoke: Smoke[] = [];
const addSmoke = (position: Vector3, spread: number, color: number, velocity: Vector3, maxLife: number, size: number) => {
const s =
spareSmoke.pop() ??
({
mesh: new Mesh(smokeGeometry, new MeshLambertMaterial({ flatShading: true, transparent: true, opacity: 0.85 })),
velocity: new Vector3(),
life: 0,
maxLife: 1,
size: 1,
} satisfies Smoke);
s.velocity.copy(velocity);
s.mesh.material.color.setHex(color);
s.mesh.material.opacity = 0.85;
offset.set(Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5);
s.mesh.position.copy(position).addScaledVector(offset, spread);
s.mesh.scale.setScalar(size * 1.5);
s.life = 0;
s.maxLife = maxLife;
s.size = size;
s.mesh.visible = true;
if (!s.mesh.parent) group.add(s.mesh);
smoke.push(s);
};
const shadowGeometry = new CircleGeometry(2.6, 12);
const shadows: Mesh[] = [];
const shadowFor = (index: number) => {
let shadow = shadows[index];
if (!shadow) {
shadow = new Mesh(
shadowGeometry,
new MeshBasicMaterial({ color: 0x1b2a1e, transparent: true, opacity: 0.35, depthWrite: false }),
);
shadow.renderOrder = 2;
group.add(shadow);
shadows[index] = shadow;
}
return shadow;
};
const zAxis = new Vector3(0, 0, 1);
const turn = new Quaternion();
return {
group,
/** Bright flash where a round strikes a plane, the ground or an obstacle. */
spark(position: Vector3) {
sparks.spawn(position, 0xffe08a, 0.14, 1);
},
/** One puff of a damage smoke trail; darkness 0 (light) .. 2 (black). */
puff(position: Vector3, drift: Vector3, darkness: number, size = 1) {
const p = puffs.spawn(position, puffColors[Math.max(0, Math.min(2, darkness))]!, 1.2 + Math.random() * 0.8, size);
p?.velocity.copy(drift);
},
/** A few pieces breaking off a damaged part. */
shards(position: Vector3, velocity: Vector3, colors: readonly number[], count = 8) {
for (let i = 0; i < count; i++) {
const p = debris.spawn(position, colors[i % colors.length]!, 3 + Math.random(), 0.6);
if (!p) return;
randomUnit(p.velocity).multiplyScalar(4 + Math.random() * 6).addScaledVector(velocity, 0.6);
p.spin.set(Math.random() * 10, Math.random() * 10, Math.random() * 10);
}
},
explode(position: Vector3, velocity: Vector3, up: Vector3, colors: readonly number[] = [P.planeBody, P.planeWing, P.planeTrim]) {
const debrisColors = [...colors, P.flame];
for (let i = 0; i < 26; i++) {
const p = debris.spawn(position, debrisColors[i % debrisColors.length]!, 2.5 + Math.random() * 1.5, 1);
if (!p) break;
randomUnit(p.velocity)
.multiplyScalar(8 + Math.random() * 16)
.addScaledVector(up, 10)
.addScaledVector(velocity, 0.15);
p.spin.set(Math.random() * 8, Math.random() * 8, Math.random() * 8);
}
for (let i = 0; i < 14; i++) {
addSmoke(position, 3, i < 5 ? P.flame : 0x5a5652, scratch.copy(up).multiplyScalar(3 + Math.random() * 4), 2 + Math.random() * 2.5, 1);
}
},
/** A rocket going off: a quick fireball and a grey cloud, no debris. */
blast(position: Vector3, up: Vector3) {
for (let i = 0; i < 9; i++) {
addSmoke(
position,
1.6,
i < 4 ? P.flame : i < 6 ? 0xffd36b : 0x6a6560,
scratch.copy(up).multiplyScalar(1.5 + Math.random() * 2),
i < 6 ? 0.45 + Math.random() * 0.3 : 1.4 + Math.random(),
i < 6 ? 0.7 : 0.5,
);
}
this.spark(position);
},
/** Advance particles and place one ground shadow per live plane. */
update(dt: number, planes: readonly { groundPoint: Vector3; up: Vector3; clearance: number; alive: boolean }[]) {
debris.update(dt, (p, t) => {
radial.copy(p.position).normalize();
p.velocity.addScaledVector(radial, -GRAVITY * dt);
p.position.addScaledVector(p.velocity, dt);
p.rotation.x += p.spin.x * dt;
p.rotation.y += p.spin.y * dt;
return p.size * (1 - t * 0.6);
});
puffs.update(dt, (p, t) => {
p.velocity.multiplyScalar(Math.exp(-dt * 1.5));
p.position.addScaledVector(p.velocity, dt);
// Grow, then shrink away (no per-puff opacity fade).
return p.size * (0.2 + 0.7 * Math.sin(Math.PI * Math.min(1, t * 1.1)) + 0.4 * t);
});
sparks.update(dt, (p, t) => {
p.rotation.z += dt * 20;
return 1 + t * 1.5;
});
let n = 0;
for (const s of smoke) {
s.life += dt;
const t = s.life / s.maxLife;
if (t >= 1) {
s.mesh.visible = false;
spareSmoke.push(s);
continue;
}
s.mesh.position.addScaledVector(s.velocity, dt);
s.mesh.scale.setScalar(s.size * (1.5 + t * 6));
s.mesh.material.opacity = 0.85 * (1 - t);
smoke[n++] = s;
}
smoke.length = n;
for (let i = 0; i < Math.max(shadows.length, planes.length); i++) {
const plane = planes[i];
const shadow = shadowFor(i);
shadow.visible = !!plane && plane.alive && plane.clearance < 80;
if (!plane || !shadow.visible) continue;
shadow.position.copy(plane.groundPoint).addScaledVector(plane.up, 0.4);
shadow.quaternion.copy(turn.setFromUnitVectors(zAxis, plane.up));
const fade = 1 - Math.min(1, plane.clearance / 80);
(shadow.material as MeshBasicMaterial).opacity = 0.4 * fade;
shadow.scale.setScalar(1 + plane.clearance / 60);
}
},
};
}
