3D · guides · Components
Custom render passes
A view draws each frame as a list of passes called its render graph. Each pass draws one part of the picture, such as the shadows, the opaque meshes or one step of an effect. Each item in view3d.passes is a Pass3D. When the view builds its graph, each enabled item adds its own passes at its stage, the point in the frame where they run. Post-processing covers the built-in items and effects made of one shader.
Drawing meshes again
A MeshPass draws every mesh that joins it a second time, with a shader of its own. Meshes join it by its name, as Mesh passes shows. OutlinePass is a mesh pass named 'outline'. It draws each mesh again in one flat colour, as a shell pushed out along its normals, the directions its surface faces.
OutlinePass draws black ink outlines on the meshes that join 'outline'. A white rim around the mesh under the pointer takes a second pass. A second OutlinePass makes the view throw, since it's also named 'outline'. Give a MeshPass a name of its own instead:
import { MeshPass, stateFor3D } from '@pixi/3d';
import { OutlinePass, solidMeshDescriptor } from '@pixi/3d/extras';
// Culls the front faces instead of the back, as OutlinePass does, so the mesh hides all of the shell but a rim
const shell = stateFor3D();
shell.clockwiseFrontFace = true;
view3d.passes = [
new OutlinePass(),
new MeshPass({
name: 'highlight',
descriptor: { shaderDescriptor: solidMeshDescriptor({ color: 0xffffff, extrude: 0.06 }), state: shell },
}),
];
mesh.eventMode = 'static';
mesh.onpointerenter = () => mesh.enablePass('highlight');
mesh.onpointerleave = () => mesh.disablePass('highlight');
- A name of your own needs a shader. It comes from the pass's
descriptor, or from a material's entry for that name in itspassesoption.solidMeshDescriptordraws in one flat colour, which you set when you create it and can't change later. The first hover can stall while the pass's shader compiles, as When shaders compile describes. - Give the pass a
statetoo. Without one, the pass uses the material's state. A pushed-out shell then covers the whole mesh, and a second draw of an opaque mesh with noextrudelands exactly on the first. The depth test keeps only what's nearer than what's already there, so it rejects that draw. Cull the front faces of a shell, as above. For a draw with noextrude, turndepthTestoff so it draws over what's already there. - Names are limited. All views on the page share a limited number of name slots, and a name past the last slot joins nothing. Use a few fixed names rather than one per object.
RenderPassBithas the details.
Effects in several steps
Writing an effect shows an effect made of one function. An effect that needs several steps overrides build and adds a FullscreenPass for each step. A FullscreenPass draws an effect(uv) function once over the whole picture. It samples the textures in its reads and draws into its writes.
A blur costs less in two steps, across and then down. That's five samples per pixel in each step, ten in all, rather than twenty-five in a single step:
import { FullscreenPass, PostEffect } from '@pixi/3d';
import type { EffectFragment, EffectIO, RenderGraph } from '@pixi/3d';
// Averages five pixels in a line, `d` apart
function blurFragment(gpuStep: string, glStep: string): EffectFragment {
const sum =
'sampleColor(uv - 2.0 * d) + sampleColor(uv - d) + sampleColor(uv) + sampleColor(uv + d) + sampleColor(uv + 2.0 * d)';
return {
gpu: `fn effect(uv: vec2<f32>) -> vec4<f32> { let d = ${gpuStep}; return (${sum}) / 5.0; }`,
gl: `vec4 effect(vec2 uv) { vec2 d = ${glStep}; return (${sum}) / 5.0; }`,
};
}
const blurAcross = blurFragment('vec2<f32>(inputTexel().x, 0.0)', 'vec2(inputTexel().x, 0.0)');
const blurDown = blurFragment('vec2<f32>(0.0, inputTexel().y)', 'vec2(0.0, inputTexel().y)');
class MyBlurEffect extends PostEffect {
public override build(graph: RenderGraph, io: EffectIO): void {
const across = graph.createColor();
graph.addPass(
new FullscreenPass({ renderer: io.renderer, fragment: blurAcross, reads: { color: io.input }, writes: across }),
);
graph.addPass(
new FullscreenPass({ renderer: io.renderer, fragment: blurDown, reads: { color: across }, writes: io.output }),
);
}
}
view3d.passes = [new MyBlurEffect()];
io.inputis the picture so far, and the last step draws intoio.output.graph.createColor()makes a texture the size of the scene for the steps in between. It lasts one frame, so it doesn't hold last frame's result.inputTexel()is the size of one pixel of the step's input inuvunits.EffectFragmentlists the other helpers.
Scene outputs and textures you keep
An effect can also read a scene output, an extra texture that the scene draw writes beside its colour. normalOutput, for example, holds each surface's normal. An item that lists an output in its requiredOutputs makes the scene write it, and reads it in build with RenderGraph.getSceneOutput. Some formats cost the scene its antialiasing, including normalOutput's format on WebGL, as The passes list says.
Like a texture from createColor, an output lasts only for the frame. ExtractPass copies one into a texture you keep, which a sprite can show:
import { ExtractPass, normalOutput } from '@pixi/3d/extras';
import { Sprite } from 'pixi.js';
const normals = new ExtractPass({ output: normalOutput });
view3d.passes = [normals];
app.stage.addChild(new Sprite(normals.texture));
Scene outputs defines two outputs of its own, each surface's base colour and its distance from the camera. It shows them beside normalOutput in three cards. Its see-through sphere is blended, so it doesn't show in any of them.
To keep a result of your own past the frame, write it into a texture you own. Add that texture to the graph with graph.importColor, which takes a function that returns it, such as () => myTexture. The graph never clears it. A FullscreenPass can't sample an imported texture yet, and trying throws. Show or read it outside the graph instead, the way a sprite shows ExtractPass's texture.
How a graph runs passes
To add passes other than a MeshPass or a PostEffect, subclass Pass3D and add them in its build at one of the three scene stages, as A sky in the feed does. graph.addPass takes either a pass, such as a FullscreenPass, or a plain object with a name, a setup and a render. These rules hold for both:
- A pass runs only when something needs what it writes. Its
setupruns once, when you add it, and uses thePassBuilderto declare which textures it reads and writes. Each frame, the graph runs the pass only when a later pass that runs reads what it writes, or when the pass writes the view's own target, a texture you imported or a camera's render texture. The view's own target is usually the screen. A pass that declares nothing never runs. A pass that only reads, such as a readback to the CPU, callsb.sideEffect()insetupso it runs anyway. - Passes run in the order you add them. What they declare doesn't reorder them, so a pass that reads a texture sees only what earlier passes wrote.
- A pass's
conditioncan skip it each frame. The graph callsprepareand thenrenderon each pass that runs.GraphRenderPasssays when each of these methods runs, andRenderGraph.getTexturecovers a pass that binds a texture itself. - A pass that draws no meshes takes
bit:RenderPassBit.NONE. Any number of passes with that bit can share a view. - Make handles in
buildorsetup, never in a constructor. A handle fromcreateColoror thePassBuilderstands for a texture the graph supplies each frame. It works only in the graph that made it. Each rebuild starts a new graph and calls each enabled item'sbuildagain. The view rebuilds after changes such as assigningview3d.passesor flipping a pass'senabled. - The graph destroys the passes that
buildadded. It does this when the view replaces the graph or the view is destroyed. You destroy the item itself, as The passes list says, so free anything it made outside the graph in an override ofdestroy.
Custom render graphs
The graph option replaces the view's graph with one that your function builds. The view calls that function on the first render and again on each rebuild. To add passes that no stage can place, start from createDefaultGraph3D and add your passes to its graph. To draw something else entirely, build a bare RenderGraph, which runs only the passes you add.
The graph below draws the opaque meshes in white into a texture. That makes a silhouette mask with no shadows, sky or lighting, and a sprite shows it:
import { Camera3D, ForwardRenderPass, RenderGraph, View3D } from '@pixi/3d';
import { solidMeshDescriptor } from '@pixi/3d/extras';
import { RenderTexture, Sprite } from 'pixi.js';
const mask = RenderTexture.create({ width: 384, height: 384 });
const maskCamera = new Camera3D({ renderTexture: mask });
maskCamera.position.set(0, 1.5, 7);
maskCamera.lookAt({ x: 0, y: 0.3, z: 0 });
const view3d = new View3D({
width: 384,
height: 384,
graph: (renderer, options) => {
const graph = new RenderGraph({
globalTextureBindings: options.globalTextureBindings,
defaultColor: (session) => session.defaultTarget.colorTexture,
defaultDepth: (session) => session.defaultTarget.depthStencilTexture!,
});
graph.addPass(
new ForwardRenderPass({
renderer,
name: 'opaque',
sort: 'key',
camera: maskCamera,
descriptor: { shaderDescriptor: solidMeshDescriptor({ color: 0xffffff }) },
}),
);
return graph;
},
});
view3d.root.addChild(maskCamera);
app.stage.addChild(view3d, new Sprite(mask));
- The
ForwardRenderPassis named'opaque', so it draws the meshes whose materials put them in the opaque pass. It uses the shader in itsdescriptorrather than the materials' own shaders. - It draws from the mask's camera, so place and move that camera. No pass here draws from
view3d.camera, so moving it changes nothing. The mask's camera has a render texture, so the view puts nothing on screen and the sprite shows the texture instead. - Give a pass's
descriptora wholeshader descriptor, such as the onesolidMeshDescriptorreturns, because a pass has no defaulttemplate. A template is the shader source that shader bits fill in, and shader bits are small pieces of shader code. Toon and custom materials covers writing shader bits.
Next steps
- Post-processing: the built-in effects and mesh passes.
- How a frame renders: where each stage runs in a frame.
- Debugging: record frames to see the passes the view built, which ones it skipped, and what each read and wrote.