npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@aeronautic/afterburner

v0.5.1

Published

Realistic jet afterburner and rocket exhaust plumes for three.js and React Three Fiber: raymarched in TSL for WebGPU, with shock diamonds, heat haze and one instanced draw

Readme

@aeronautic/afterburner

npm CI MIT

A twin-engined fighter banking, both afterburners lit, rendered with @aeronautic/afterburner

Raymarched jet and rocket plumes for React Three Fiber, written in TSL for three's WebGPURenderer.

Each plume is a signed distance field, marched inside an instanced proxy hull: shock diamonds, a potential core, stretched eddies, a helical mode, soot and heat haze. Every plume sharing a batch is one instanced draw, with distance tiers so hundreds of nozzles stay cheap. Presets cover a clean afterburner, turbulent reheat, kerolox, hydrolox and methalox rockets, a solid booster and a plasma thruster, and the shader is exported piece by piece for building your own.

Docs · Live examples

The docs have a tutorial, guides, live examples, the full reference and how the shader works. This README is the short version. For the vapor a wing pulls in humid air, see @aeronautic/wing-vapor.

Install

pnpm add @aeronautic/afterburner @aeronautic/core three@~0.186

Peer dependencies: @aeronautic/core, three 0.186.x, plus react 19 and @react-three/fiber 9 (or 10) for the components in @aeronautic/afterburner/react. The root, /physics and /tsl entries need no React. Each release supports one three minor, because TSL changes between them. It needs a WebGPURenderer (which falls back to WebGL 2 by itself); it does not run on the classic WebGLRenderer. In R3F, gl={webgpu_gl()} from @aeronautic/core/react makes one.

Quick start

<Afterburner> is a group where a nozzle sits. The plume streams out along the group's local +Z, aft of a model that flies along -Z, and follows the group every frame. That is all a scene needs: nozzles with no batch above them share one made for their scene and preset.

import { Canvas } from "@react-three/fiber";
import { Afterburner } from "@aeronautic/afterburner/react";
import { webgpu_gl } from "@aeronautic/core/react";

const Jet = () => (
  <group>
    <JetModel />
    {/* On the nozzle's exit plane, 14 m aft of a model flying along -Z */}
    <Afterburner
      preset="afterburner"
      position={[0, 0, 14]}
      params={{ nozzleRadiusM: 0.55 }}
    />
  </group>
);

export const App = () => (
  <Canvas gl={webgpu_gl()}>
    <Jet />
  </Canvas>
);

The radiance is graded for exposure 1 with a filmic tone map (the docs use AgXToneMapping) and reads best with a little bloom on top. Every distance is in metres.

Presets

| name | what it is | | ----------------------- | ---------------------------------------------------------------------------- | | afterburner | A clean military reheat: blue core, crisp diamonds, almost no turbulence. | | afterburner_turbulent | The same can, churning: the defaults. | | rocket_kerolox | RP-1 and LOX: yellow-orange, sooty, opaque and very turbulent. | | rocket_hydrolox | LH2 and LOX: nearly invisible violet with bright Mach disks and strong haze. | | rocket_methalox | Methane and LOX: blue-violet root, pink-orange tail, clear diamonds. | | solid_booster | An APCP booster: huge, white-yellow, dense and ragged. | | plasma | A cyan, laminar, sci-fi drive. |

A preset sets both the per-nozzle params and the batch's shared profile. The params are physical quantities in SI units: nozzle radius, exit Mach, exit pressure ratio, exit temperature, gamma, molar mass, soot and particles. The plume's length, width and shock spacing are not dials; they come out of the jet those numbers make, so { nozzleRadiusM: 2 } on a rocket preset gives a bigger rocket, not a stubby one. Colour comes from temperature through Planck's law, except the gas's own band emission (bandColor).

Shaped nozzles

The exit needn't be round. Its outline is a superellipse, an ellipse at squareness 2 and a rounded rectangle above, and the jet mixes round downstream:

<Afterburner
  preset="afterburner"
  params={{
    nozzleRadiusM: 0.27, // the round exit of the same area
    nozzleAspect: 1.4, // width over height
    nozzleSquareness: 2, // 1 a diamond, 2 an ellipse, 4+ a rounded rectangle
    nozzleRoll: 0, // radians about the plume's axis
    nozzleOutlineLength: 1, // how long the shape lasts, in core lengths
  }}
/>

Any other outline is the outline hook, below.

Flight and propellant

The profile puts the engines in the air:

  • altitudeM and temperatureOffsetK give the International Standard Atmosphere's pressure, temperature and density. A rocket's plume balloons and thins as it climbs; leftover fuel and soot stop burning once there is too little oxygen.
  • airspeedMPerS slows the mixing layer, lengthening the core and the shock train, and rams a jet engine's inlet.

A liquid rocket can be given its fuel and mixture ratio instead of raw soot and afterburning numbers; against a preset of the same fuel it also scales the exhaust temperatures:

<Afterburner
  preset="rocket_kerolox"
  params={{ propellant: { fuel: "kerosene", mixtureRatio: 2.0 } }}
/>

atmosphere, propellant_effects, propellant_params and jet_state are exported for working these out on the main thread.

Inside an @aeronautic/core <FlightProvider>, all of this comes from the flight. The batch takes altitudeM, airspeedMPerS and temperatureOffsetK from it, and a nozzle with no throttle prop follows the flight's throttle. The flight is read only when it changes, inside the batch's own frame update, so nothing renders:

<FlightProvider track={jet}>
  {/* throttle, altitude and airspeed from the flight */}
  <Afterburner position={[0, 0, 6]} />
</FlightProvider>

A number for throttle, or a write to the handle's throttle, takes over from the flight. A function reads the flight its own way, and is called only when the flight changes: throttle={(f) => f.throttle * 0.9} for an engine spooling short of the other. source={null} ignores the flight altogether.

Inside an @aeronautic/controls <Engine>, a nozzle sits on that engine's exhaust and runs at its throttle. It also turns with the nozzle when the engine vectors:

<Engine side="left">
  <Afterburner />
</Engine>

A preset can also be an object of your own, { params, profile }, and AFTERBURNER_PRESETS is there to spread from:

const sunset_booster = {
  params: {
    ...AFTERBURNER_PRESETS.solid_booster.params,
    bandColor: "#ff5a1f",
  },
  profile: AFTERBURNER_PRESETS.solid_booster.profile,
};

API

<Afterburner>

Takes every <group> prop (position, rotation, scale, children…), plus:

| prop | type | default | what it does | | ----------- | -------------------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------- | | preset | AfterburnerPresetName \| AfterburnerPreset | the defaults | Which look to start from. Outside an <AfterburnerBatch> it also picks the batch, and so the profile. | | params | AfterburnerParamsInput | — | How this plume looks, over the preset. Colours take any ColorRepresentation. | | throttle | number \| (flight) => number | the flight's, else 1.1 | 0 to 1 is dry thrust, on to 1.1 at full reheat. A jet's burner lights past 1; a rocket's is always lit. | | source | Flight \| null | nearest | The flight it follows. null follows none. | | batch | AfterburnerBatchHandle | nearest | Draw it with this batch rather than the nearest one. | | target | Object3D \| null | its <Engine>'s exhaust | Sit on this mesh, group or bone from anywhere in the scene. position/rotation are then in its frame. | | direction | [x, y, z] | — | Which way the exhaust streams, in the frame it sits in. Wins over rotation. | | ref | AfterburnerHandle | — | Imperative handle, see below. |

params is compared field by field, so an inline object is fine. A change rewrites only this nozzle's instance. The group's scale scales the plume, look and all.

AfterburnerHandle (ref)

Stable for the component's life, and safe to write before the nozzle is in a batch.

type AfterburnerHandle = {
  throttle: number; // cheap to write every frame; the plume eases toward it
  setParams: (params: AfterburnerParamsInput) => void; // replaces them all
  updateParams: (params: AfterburnerParamsInput) => void; // merges
  setOffset: (offset: AfterburnerOffset) => void; // move or turn it
  setDirection: (direction: [number, number, number]) => void;
  readonly params: Readonly<AfterburnerParams> | null;
  readonly object: Group | null;
  readonly nozzle: AfterburnerNozzle | null;
  readonly batch: AfterburnerBatchHandle | null;
};

To drive the throttle every frame without a render:

const engine = useRef<AfterburnerHandle>(null);

useFrame(({ clock }) => {
  if (engine.current) {
    engine.current.throttle = 0.95 + 0.15 * Math.sin(clock.elapsedTime * 3);
  }
});

<Afterburner ref={engine} />;

Placing it on a model

The plume leaves the group's origin along its local +Z. Nested in JSX, it follows its parent like any child. For a node of a loaded model, or anything else not in your JSX, pass it as target. The nozzle then sits on it, and position and rotation (or direction) are in its frame:

const { nodes } = useGLTF("/jet.glb");

<Afterburner
  target={nodes.Engine}
  position={[0, 0, -4.2]} // the nozzle exit, in the engine's frame
  direction={[0, 0, -1]} // exhaust out of the back
/>;

To move it from code, write to the handle's object (it is the group) or use setOffset:

engine.current?.setOffset({
  position: [0, 0.2, -4.2],
  direction: [0, -0.1, -1],
});

AfterburnerOffset takes position as a tuple or vector, rotation as an Euler, a Quaternion or a tuple of either, and direction.

Changing the air from code

Per nozzle, updateParams changes the fields given and keeps the rest. The altitude, airspeed and the day are the batch's: updateProfile does the same for them. It only writes uniforms, so it is cheap every frame:

const batch = useRef<AfterburnerBatchHandle>(null);

useFrame(() => {
  batch.current?.updateProfile({ altitudeM: aircraft.position.y });
});

<AfterburnerBatch ref={batch}>…</AfterburnerBatch>;

Either one is overridden again when its prop changes.

<AfterburnerBatch>

For taking control of the batch its children draw with.

| prop | type | default | what it does | | ------------------- | ----------------------------------------------------------------------- | -------- | --------------------------------------------------------------------- | | preset | AfterburnerPresetName \| AfterburnerPreset | — | The profile to start from, and the look of nozzles that name none. | | profile | Partial<AfterburnerProfile> | — | The plume shape every nozzle shares, as uniforms. Applies live. | | quality | "low" \| "medium" \| "high" \| "ultra" \| Partial<AfterburnerQuality> | "high" | Step counts are uniforms; only an octave change recompiles. | | haze | boolean | true | Heat haze. Compiled in only when some nozzle has refractionM > 0. | | hooks | AfterburnerHooks | — | TSL to change the field or the pixel, see below. A change recompiles. | | timeScale | number | 1 | Seconds of flame per second. | | detailDistanceM | number | 900 | Past this, eddies are dropped. | | cheapDistanceM | number | 3000 | Past this, a plume is a single sample. | | minScreenFraction | number | 0.001 | Plumes smaller than this share of the screen height are not drawn. | | pass | AfterburnerPass | — | Draw the plumes in their own pass, see below. A change rebuilds. | | source | Flight \| null | nearest | The flight whose air and airspeed the profile follows. null: none. | | ref | AfterburnerBatchHandle | — | The batch, for stats() or to drive it directly. |

useAfterburnerBatch() returns the nearest batch from inside one.

Without React

AfterburnerBatch is the plain three.js class underneath:

import { AfterburnerBatch } from "@aeronautic/afterburner";

const batch = new AfterburnerBatch({ preset: "rocket_kerolox" });
scene.add(batch.mesh);

const nozzle = batch.add({
  object: engine_bell,
  params: { nozzleRadiusM: 1.2 },
});
nozzle.throttle = 1.05; // the burner part-way in

// Or on a mesh, wherever it is, at an offset in its frame
const tail = batch.add({
  object: engine_node,
  offset: { position: [0, 0, -4.2], direction: [0, 0, -1] },
});
tail.setOffset({ position: [0, 0.1, -4.2] });
tail.attach(other_engine); // follow something else; attach(null) holds still
tail.updateParams({ exitMach: 1.6 });
batch.updateProfile({ altitudeM: 9000 });

batch.stats(); // { nozzles, near, mid, far, culled }

A nozzle follows its object's world matrix, or a matrix you write yourself. The batch updates in the mesh's onBeforeRender, once a frame however many cameras draw it.

Changing the shader

Hooks

Hooks are TSL functions, built into the material when it compiles. They are the easy way to change the look without forking:

import { atan, cos, mix, vec3 } from "three/tsl";

const hooks: AfterburnerHooks = {
  // The temperature at a sample, in kelvin: 10% hotter on the axis
  temperature: (kelvin, sample) =>
    kelvin.mul(sample.across.oneMinus().max(0).mul(0.1).add(1)),

  // What a sample emits per metre, before exposure
  emission: (rgb, sample) =>
    mix(rgb, rgb.mul(vec3(1, 0.8, 0.6)), sample.mixture),

  // Your own eddies, in [0, 1], over flow space
  turbulence: (flow) => my_noise(flow),

  // The final pixel, in linear radiance
  radiance: (rgb, pixel) => rgb.mul(1.2),

  // Any nozzle outline: its radius in each direction, in nozzle radii, and
  // the most it ever is. Here eight lobes, 15% deep
  outline: {
    radius: (direction) =>
      cos(atan(direction.y, direction.x).mul(8)).mul(0.15).add(1),
    reach: 1.15,
  },
};

<AfterburnerBatch hooks={hooks}>…</AfterburnerBatch>;

Make hooks stable (a module constant or useMemo): a new object recompiles.

The TSL itself

@aeronautic/afterburner/tsl exports every piece the material is made of, for building a material of your own:

  • Noise: hash_cell, value_noise, wander_noise, turbulence, signed.
  • Light: blackbody, temperature to linear radiance from a Planck lookup.
  • The jet: plume_jet (the fully expanded jet from the nozzle, the gas and the air), plume_half_width, plume_centreline.
  • The field: plume_frame (a point in the nozzle's frame to its station and mixing layer), plume_field (temperature, mixture, extinction and emission there), plume_bound (how far a march may safely step), plume_outline (a point measured against the nozzle's outline), plus plume_span, plume_closest and plume_station.
  • The material: create_afterburner_material, create_afterburner_uniforms, write_afterburner_profile and AFTERBURNER_ATTRIBUTES (the instance attribute names).

Each takes its inputs explicitly through a PlumeContext ({ jet, profile, time, blackbody }), so it works from any material, instanced or not. The profile nodes include air, the atmosphere write_afterburner_profile works out on the CPU.

Performance

  • One draw call per batch, whatever the nozzle count. Only nozzles whose matrix changed are re-uploaded, by update range.
  • Four tiers per plume, picked in the vertex shader: near (full march with eddies), mid (no eddies), far (one sample) and culled.
  • The march skips empty space by a conservative bound and stops once the flame is opaque.
  • Steps are shared out by screen area: a plume filling the frame up close gets fewer, down to the mid tier's count, so a frame costs about the same however near the camera comes.
  • quality presets trade steps for speed; low suits phones.

Half resolution

A raymarch costs per pixel, and a plume is soft enough to lose little at half the resolution. @aeronautic/core's volume_pass draws the plumes, and any wing's vapor with them, in a pass of their own at a share of the frame and lays them back over the scene. The upsample is weighted by depth, so a nozzle's edge stays sharp. Close up and astern of a fighter it takes the worst case from 6.8 ms to 2.7 ms.

import { volume_pass } from "@aeronautic/core";
import { VolumePassContext } from "@aeronautic/core/react";

const split = volume_pass(scene, camera, { resolutionScale: 0.5 });

render_pipeline.outputNode = split.output; // then bloom it, grade it…

<VolumePassContext value={split}>…</VolumePassContext>;

split.output replaces your scene pass: it is the scene with the plumes on it. Every plume inside the context draws in the pass. Without React, give AfterburnerBatch backdrop: split.backdrop and add its mesh to split.scene. afterburner_pass is the same pass, under its older name.

How it works

How it works, in the docs, covers why the plume is a distance field, what each dial means physically, why shock diamonds need the camera nearly abeam, and how the tiers work.

How it was made

This library was written with AI, under human supervision throughout: every change was directed, reviewed and checked in the browser by its author. Performance was the main goal from the start, and every choice that costs GPU time was measured.

License

MIT © Romain Le Gall