aeronautic

Volumes at half resolution

Draw every plume and every wing's vapor in one pass at a share of the frame, depth-aware.

The afterburner's plumes and the wing's vapor are raymarched: each costs per pixel it covers, and each is soft enough to lose little at half the resolution. volume_pass draws them all in a pass of their own, at a share of the frame, and lays them back over the full-resolution scene.

The opaque scene is drawn first. The volumes are then drawn alone into the smaller target, reading the scene's depth to stop at what is in front of them and its colour to see through. Every volume of the aeronautic effects blends premultiplied, so any number of them share the one target. The upsample is weighted by depth: a low-resolution texel only lends its volume to pixels whose scene is at about the same depth, so a canopy or a nozzle in front of a flame keeps its edge.

Drawn this way, the On a model page at 2560×1440, the effects went from 0.59 ms to 0.15 ms of GPU a frame.

With React

Make the pass from the R3F scene and camera, use its output as the render pipeline's, and put it in a VolumePassContext round the effects. Every <Afterburner>, <AfterburnerBatch> and <WingVapor> inside draws in it:

import { useFrame, useThree } from "@react-three/fiber";
import { volume_pass } from "@aeronautic/core";
import { VolumePassContext } from "@aeronautic/core/react";
import { type FC, type ReactNode, useEffect, useMemo } from "react";
import { bloom } from "three/addons/tsl/display/BloomNode.js";
import { RenderPipeline, type WebGPURenderer } from "three/webgpu";

const Volumes: FC<{ children?: ReactNode }> = ({ children }) => {
  const { gl, scene, camera } = useThree();
  const renderer = gl as unknown as WebGPURenderer;

  const split = useMemo(() => volume_pass(scene, camera), [scene, camera]);

  const pipeline = useMemo(() => {
    const pipeline = new RenderPipeline(renderer);

    // split.output is the scene with the volumes on it: bloom it, grade it…
    pipeline.outputNode = split.output.add(bloom(split.output, 0.3, 0.2, 1));

    return pipeline;
  }, [renderer, split]);

  useEffect(() => () => pipeline.dispose(), [pipeline]);
  useFrame(() => pipeline.render(), 1);

  return <VolumePassContext value={split}>{children}</VolumePassContext>;
};

<Canvas gl={webgpu_gl()}>
  <Volumes>
    <Jet />
  </Volumes>
</Canvas>;

<AfterburnerBatch> and <WingVapor> take a pass prop too. Left out, it is the nearest VolumePassContext's; give them another pass, or null to draw in the main scene at full resolution. A lone <Afterburner> follows the context. useVolumePass() reads the nearest one, or null.

Changing the pass an effect draws in rebuilds it.

Anything that walks the scene to set up its meshes, a light rig say, should walk split.scene as well: the effects' meshes are there, not in yours.

Options

optiondefaultwhat it is
resolutionScale0.5The volumes' resolution against the frame's. 0.5 is a quarter of the pixels.
backdropNodenoneWhat the opaque scene becomes before the volumes go over it. See below.

What it returns

fieldwhat it is
outputThe scene with the volumes composited, at full resolution. Your pipeline's input.
sceneThe volumes' own scene. Cleared to transparent black, and fogged as yours is.
backdropThe opaque scene's color, as backdropNode made it, and its depth.
scenePassThe opaque scene at full resolution, without the volumes.
volumePassThe volumes alone, premultiplied, at the reduced resolution.

Without React

Build each effect with the pass's backdrop, and add its mesh to the pass's scene rather than yours:

import { volume_pass } from "@aeronautic/core";
import { AfterburnerBatch } from "@aeronautic/afterburner";
import { WingVapor } from "@aeronautic/wing-vapor";

const split = volume_pass(scene, camera);

const plumes = new AfterburnerBatch({
  preset: "afterburner",
  backdrop: split.backdrop,
});
const vapor = new WingVapor({ object: jet, backdrop: split.backdrop });

split.scene.add(plumes.mesh, vapor.mesh);

pipeline.outputNode = split.output;

Fog

A volume's light comes from all along its ray, so three's fog, which fogs a surface at its own depth, cannot fog it. The plumes and the vapor read the scene's Fog or FogExp2 themselves and fog each sample at its own depth, in a volume pass or out of one. The pass's scene takes your scene's fog every frame.

Grading the scene under the volumes

backdropNode takes the opaque scene's colour and depth and returns what it should become before the volumes are laid over it: an atmosphere's aerial perspective, say, which also draws the sky wherever nothing was drawn.

const split = volume_pass(scene, camera, {
  backdropNode: ({ color, depth }) => aerialPerspective(color, depth),
});

Grading the composited frame instead goes wrong in two ways. A sky drawn where the depth is clear would be drawn over every plume and every wisp of vapor in front of it, since they write no depth. And a plume's heat haze refracts what is behind it, which should be the graded scene, not the raw one. With backdropNode the grade is drawn once a frame to a texture of its own; the volumes read it as their backdrop and are composited over it. See takram's atmosphere.

When not to use it

  • Volumes that only cover a few pixels gain little.
  • If you already render the whole frame at a reduced resolution, there is nothing to win.
  • The dither's grain becomes two pixels wide. Bloom hides it; a frame with none may show it.

On this page