aeronautic

1. Your first jet

Build a graded WebGPU scene with one engine and a throttle slider.

In this tutorial you'll build the scene below: one engine can, its plume, bloom, and a throttle slider. Parts 2 and 3 put it on a moving model and drive it from code.

basic-jet.tsx
Starting…

The renderer

Start from an empty React app with three, @react-three/fiber, @react-three/drei and @aeronautic/afterburner installed. The canvas needs a WebGPURenderer. Initialise it before handing it back:

app.tsx
import { Canvas } from "@react-three/fiber";
import { webgpu_gl } from "@aeronautic/core/react";

export const App = () => (
  <Canvas camera={{ position: [-16, 4, 10], fov: 40 }} gl={webgpu_gl()}>
    <Scene throttle={1.1} />
  </Canvas>
);

antialias: false because the plume is soft, and the bloom pass below does not need a multisampled buffer.

Bloom and tone mapping

The plume's radiance is physical, and its brightest parts are meant to clip: the white-hot root and the Mach disks. Bloom the scene, then tone map it with AgX:

grade.tsx
import { useFrame, useThree } from "@react-three/fiber";
import { useEffect, useMemo } from "react";
import { AgXToneMapping } from "three";
import { bloom } from "three/addons/tsl/display/BloomNode.js";
import { pass } from "three/tsl";
import { RenderPipeline, type WebGPURenderer } from "three/webgpu";

export const Grade = () => {
  const { gl, scene, camera } = useThree();
  const renderer = gl as unknown as WebGPURenderer;

  const pipeline = useMemo(() => {
    const color = pass(scene, camera).getTextureNode("output");
    const pipeline = new RenderPipeline(renderer);

    // strength, radius, threshold: only what clips blooms
    pipeline.outputNode = color.add(bloom(color, 0.45, 0.2, 1));

    return pipeline;
  }, [renderer, scene, camera]);

  useEffect(() => {
    renderer.toneMapping = AgXToneMapping;
    return () => pipeline.dispose();
  }, [renderer, pipeline]);

  // Priority 1 takes over rendering from R3F
  useFrame(() => pipeline.render(), 1);

  return null;
};

The engine

The plume leaves the <Afterburner> group's origin along its +Z. Put the engine can behind the origin, on −Z, so the plume comes out of its mouth:

scene.tsx
import { OrbitControls } from "@react-three/drei";
import { Afterburner } from "@aeronautic/afterburner/react";
import { Grade } from "./grade";

const Nozzle = () => (
  // A cylinder is along Y; a quarter turn about X lays it along Z
  <mesh position={[0, 0, -1]} rotation={[-Math.PI / 2, 0, 0]}>
    <cylinderGeometry args={[0.54, 0.6, 2, 32, 1, true]} />
    <meshStandardMaterial color="#3d424c" metalness={0.6} side={2} />
  </mesh>
);

export const Scene = ({ throttle }: { throttle: number }) => (
  <>
    <color attach="background" args={["#05070b"]} />
    <hemisphereLight args={["#8090b0", "#101010", 0.6]} />
    <Nozzle />
    <Afterburner preset="afterburner" throttle={throttle} />
    <OrbitControls target={[0, 0, 8]} />
    <Grade />
  </>
);

The afterburner preset's nozzle is 0.5 m in radius. Every length in the plume comes from that radius and the gas, so the cells and the core are already the right size.

The throttle

throttle runs from 0 to 1.1. Up to 1 the engine is on dry thrust, from idle to military power. Its exhaust is too cool to see, as a real one is: only the turbine glows a dull red up the nozzle, seen from behind. Past the profile's burnerThreshold, 1 by default, the burner lights, and it is in fully at 1.1. The plume eases toward a new throttle over a quarter of a second, the batch's responseS, so the light-off is a moment rather than a frame. Keep it in state and pass it down:

const [throttle, set_throttle] = useState(1.1);

<input
  type="range"
  min={0}
  max={1.1}
  step={0.01}
  value={throttle}
  onChange={(event) => set_throttle(Number(event.target.value))}
/>;

Changing a prop re-renders. That's fine for a slider. For a value that changes every frame, write it through a ref instead, as in part 3.

Next: put it on a model.

On this page