Rendering

Effects

Decals, outlines and highlights, camera shake, and the dynamic-mesh path for content whose vertices change every frame.

meep’s rendering effects are ECS systems. You register the system, attach a component, and the system registers a render extension that draws at a declared phase of Shade’s frame. The phase decides what an effect can see and what can cover it: decals run before anything is lit, so lighting, ambient occlusion and reflections all see them; outlines run after transparency, so the fog of war conceals them along with what they outline.

EffectSystemComponentsDraws at
DecalsDecalSystem(graphics, assets)Decal, Transform64AfterGBuffer, after the terrain
Outlines / highlightsHighlightOutlineSystem(graphics) + a source system per kindHighlight + SGMesh, Highlight + ShadedGeometryAfterTransparency
TrailsTrail3DSystem(graphics)Trail3D, Transform64AfterTransparency
Camera shakeCameraShakeBehavior, CameraShakeTraumaBehavior-not a render pass at all
Your own geometryGPUDynamicMeshRenderer inside your own extension-wherever you register it

Every one of these systems takes engine.graphics (the GraphicsEngine facade) as its first argument, not the engine. The <Name>3 files beside each module in src/engine/graphics3/ are deprecated aliases; import the unsuffixed name.


Decals

A decal is a surface override, not a tint. It carries albedo, a tangent-space normal, roughness, metalness and emission, and it writes them into the G-buffer before anything is lit - so ambient occlusion, screen-space reflection and shading all see a decal exactly as they see the surface it is painted on. A scorch mark dulls the specular response of the floor it is on; a glowing rune actually emits.

Decals reach opaque surfaces only, by construction. The G-buffer is all they touch and transparency composites later, so a decal will never appear on water.

import { DecalSystem } from "@woosh/meep-engine/src/engine/graphics3/DecalSystem.js";
import { Decal } from "@woosh/meep-engine/src/engine/graphics/ecs/decal/v2/Decal.js";

await em.addSystem(new DecalSystem(engine.graphics, engine.assetManager));

// Attach a decal to an entity that also has a Transform64
entity.add(Decal.fromJSON({
    uri_albedo: "data/textures/decals/scorch_albedo.png",
    uri_normal: "data/textures/decals/scorch_normal.png",
    uri_orm: "data/textures/decals/scorch_orm.png",
    priority: 0,
    color: "#ffffff"
}));

DecalSystem declares dependencies = [Decal, Transform64], and its startup() registers a DecalExtension (name = "decals", phase = FramePhase.AfterGBuffer, after = [TerrainExtension]). That last declaration is why scorch marks land on top of the textured ground rather than under it - the terrain paints its splat mix in the same phase, and the decal composite has to see the result. The ordering is declared, so the order you register the two systems in does not matter.

The component

Decal is a named export from src/engine/graphics/ecs/decal/v2/Decal.js. Its fields:

FieldTypeNotes
uri_albedostringBase colour; its alpha is the decal’s coverage
uri_normalstringTangent-space normal, applied in the decal’s own frame
uri_ormstringglTF packing: G is roughness, B is metalness. R (occlusion) is deliberately ignored, so an authored glTF ORM texture drops in unmodified
uri_emissivestringGreyscale mask, multiplied by emissive_color and emissive_intensity
uristringDeprecated. It shadows uri_albedo while both are set, so content authored with it keeps working
roughnessnumberMultiplies whatever uri_orm gives; default 1, so a decal with no ORM map is simply this value
metalnessnumberSame, default 0
emissive_colorColorDefault white
emissive_intensitynumberZero by default, so a decal that does not set it is non-emissive. Values above one are the point of it being a float
prioritynumberDraw order when decals overlap
colorColorPremultiplied into the decal

The entity’s Transform64 is the projector: a unit box, scaled to the volume you want, whose world matrix and inverse are what the GPU record carries. It projects along its local +Z, into the surface. The coverage fade is smoothstep(0.35, 0.6, dot(face_normal, -axis_z)), so a projector built by pointing +Z along the surface normal scores -1 and every pixel is rejected - silently: the entity links, the texture resolves, the record is well-formed, and nothing is drawn. Aim +Z at the surface.

What to expect at runtime

  • A decal is not packed until its albedo image has loaded. Drawing it with a placeholder would put a white square on the ground until the image landed, so it simply does not appear yet.
  • Textures are reference counted across decals. Combat spawns a footprint decal per step and every one of them names the same file; packing a patch per footprint would repack three atlases and regenerate their mip chains several times a second. There are three atlases - albedo (sRGB), normal and ORM - because their contents want different mip filters and different border fills.
  • Records are rebuilt every frame, on purpose. The froxel binning depends on the camera and has to run every frame regardless, and rebuilding the records alongside it means nothing has to watch a decal for changes - which matters, because a Color’s change signal does not fire for alpha-only edits.
  • Capacity grows with the record buffer. There is no fixed budget on the number of decals you may link; the system grows its record array to fit. What is bounded is the GPU side: DECAL_CULL_LIMIT = 4095 decals survive culling in a frame, and DECAL_CLUSTER_LOCAL_LIMIT = 32 fit in one froxel - deliberately smaller than the light limit, because a froxel crowded with decals is a pile of surface overrides where all but the last few are invisible.
  • The decal froxel grid has the lighting grid’s shape by construction: 32-pixel tiles, 24 Z slices.
  • When nothing is packed, the pass does not run at all and the frame keeps the G-buffer it already had.

Outlines and highlights

An outline is a neighbourhood test on the visibility buffer, which already says which mesh every pixel came from. Nothing is drawn twice: the pass reads the mesh id at each pixel, looks it up in a per-frame colour table, and paints a pixel that is not itself highlighted but has a highlighted mesh within thickness of it. The outline therefore sits outside the silhouette - a highlight marks a unit without painting over it - and the neighbourhood is a disc, so the edge is the same width in every direction. Where two highlights are close enough to overlap, the one with the higher coverage wins.

import {
    HighlightOutlineSystem
} from "@woosh/meep-engine/src/engine/graphics3/HighlightOutlineSystem.js";
import {
    SGMeshHighlightSystem
} from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/aggregate/SGMeshHighlightSystem.js";
import {
    ShadedGeometryHighlightSystem
} from "@woosh/meep-engine/src/engine/graphics/ecs/highlight/system/ShadedGeometryHighlightSystem.js";
import { MeshSystem } from "@woosh/meep-engine/src/engine/graphics3/MeshSystem.js";
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import { load_model_scene_bundle } from "@woosh/meep-engine/src/engine/asset/load_model_scene_bundle.js";
import Highlight from "@woosh/meep-engine/src/engine/graphics/ecs/highlight/Highlight.js";
import { HighlightDefinition } from "@woosh/meep-engine/src/engine/graphics/ecs/highlight/HighlightDefinition.js";

const meshes = new MeshSystem(
    engine.graphics,
    EngineHarness.shadeScene(engine),
    url => load_model_scene_bundle(engine.assetManager, url)
);
await em.addSystem(meshes);

// the pass. It observes no entities: what is highlighted is contributed to it.
const outline = new HighlightOutlineSystem(engine.graphics);
await em.addSystem(outline);

// one source system per kind of highlighted thing. Register the ones you need.
await em.addSystem(new SGMeshHighlightSystem(outline, meshes));   // loaded models
await em.addSystem(new ShadedGeometryHighlightSystem(outline));   // primitives

// Highlight an entity red
const h = new Highlight();
h.add(HighlightDefinition.rgba(1, 0.1, 0.1, 1.0));
entity.add(h);

// or, for the single-colour case
entity.add(Highlight.fromOne(1, 0.8, 0.2, 1));

Highlight is a default export; HighlightDefinition is named. HighlightOutlineSystem and HighlightExtension are both named exports of the same module, and the extension records at FramePhase.AfterTransparency.

One pass, several sources

The split is the point. HighlightOutlineSystem owns everything about drawing: the extension, the per-frame colour table and the neighbourhood walk. It declares no component tuple at all, because what an entity has in the scene depends on what kind of thing it is - a model’s rows come from MeshSystem, a primitive’s from its own component - and a pass per kind would mean each one reading the whole visibility buffer and painting over the last.

So the pairings are systems and the drawing is one:

Source systemTupleConstructorFinds rows through
SGMeshHighlightSystem[Highlight, SGMesh](outline, meshes)the mesh system’s expansion of the loaded model
ShadedGeometryHighlightSystem[Highlight, ShadedGeometry](outline)the component’s own node, which is exactly one row

Each contributes entries through outline.add_entry(entity, highlight, traverse) and hands them back on unlink, and an entity that is both a model and a primitive contributes one entry from each. graphics3/HighlightSystem.js resolves - it re-exports SGMeshHighlightSystem under that name for instanceof and getSystem - but its constructor is (outline, meshes), not (graphics, meshes): outlining is the two lines above.

Two things to plan for

  1. Highlights do not show through occluders. The visibility buffer knows only what was rasterized, so an entity is outlined where it can be seen and nowhere else. That is the cost of not drawing the scene a second time; a UI that needs a selected unit visible through walls needs a different affordance.
  2. A primitive needs its own source system. A Highlight on a bare ShadedGeometry draws nothing unless ShadedGeometryHighlightSystem is registered - the outline pass only knows what a source system tells it.

Thickness

system.thickness is the outline width in pixels, and it is the one public tunable:

outline.thickness = 4;

It defaults to HIGHLIGHT_OUTLINE_THICKNESS = 3 - a hard edge at that width reads as a deliberate mark, where a soft one reads as a glow. The same module exports HIGHLIGHT_OUTLINE_MAX_THICKNESS = 8, the widest outline the pass is meant to draw. Nothing clamps thickness to it; it is a ceiling on what the shape is good for. The neighbourhood is walked per pixel, so the pass’s whole cost is (2r+1)² loads of an integer texture - 49 at the default, 441 at ten.

Multiple definitions

Highlight holds a list of HighlightDefinitions, each carrying an RGBA Color. They composite over in list order, premultiplied, into one rgba8 word per mesh row. A fully faded set packs to 0, which the shader reads as “no outline”, so animating alpha to zero is the way to switch a highlight off.

The colour table is repacked every frame. That is cheaper than the alternative rather than a concession: highlights animate their colour and alpha continuously, and a Color’s change signal does not fire for alpha-only edits, which is exactly what that animation is.

Because the outline is drawn into the scene at AfterTransparency, the fog of war (which declares itself after the highlight, trail, particle and path extensions) conceals it along with what it outlines.


Ribbons and trails

Trail3D is the one trail component the engine draws, and it has its own page: see Trails for the component surface, the sizing rules and make_gradient_stroke, which authors a beam or a streak. There is no screen-facing ribbon: Trail2D deserializes but nothing renders it.

If what you actually want is arbitrary geometry you rewrite yourself each frame, that is the dynamic-mesh path below - it is the machinery Trail3D is built on.


Camera shake

Camera shake is a pair of Behavior subclasses in src/engine/graphics/camera/. It touches no render pass; it moves a TopDownCameraController.

CameraShake (CameraShake.js) owns six independent 2D simplex noise streams - yaw, pitch, roll and X/Y/Z offset - seeded from a fixed value so a replay shakes identically. shake.read(value, time, offset, rotation) writes into the offset and rotation Vector3s you hand it, scaled by value (0-1) and clamped by limitsOffset / limitsRotation.

CameraShakeBehavior wraps a CameraShake and updates the controller’s target, pitch, yaw and roll every tick. It takes a single options object; the limits are set at construction:

import { CameraShakeBehavior } from "@woosh/meep-engine/src/engine/graphics/camera/CameraShakeBehavior.js";

const shakeBehavior = new CameraShakeBehavior({
    controller,
    maxPitch: 0.05,
    maxYaw:   0.05,
    maxRoll:  0.02,
    maxOffsetX: 0.1,
    maxOffsetY: 0.1,
    strength: 0,
});

CameraShakeTraumaBehavior adds a trauma accumulator on top. trauma is clamped to 0-1, decays at decay units per second, and is mapped through a cubic curve before being written to the underlying behaviour’s strength. Add trauma in response to impacts:

import { CameraShakeTraumaBehavior } from "@woosh/meep-engine/src/engine/graphics/camera/CameraShakeTraumaBehavior.js";

const traumaBehavior = new CameraShakeTraumaBehavior({
    shakeBehavior,
    decay: 1.5  // full trauma decays in ~0.67 s
});

// On explosion:
traumaBehavior.trauma = Math.min(1, traumaBehavior.trauma + 0.6);

Both behaviours are based on Squirrel Eiserloh’s 2016 GDC talk “Math for Game Programmers: Juicing Your Cameras With Math”.


Dynamic meshes

A trail behind a projectile, a tube along a path, a debug line: content whose vertices are rewritten every frame cannot pay for a meshlet build, and rebuilding meshlets per frame to force it down the standard path is explicitly not the answer. Shade gives it a path of its own.

This is a separate path, not a variant of the standard one. A DynamicMesh holds a plain Geometry rather than a MeshletGeometry, is never in the scene’s instance batch, gets no row in the scene database, and is drawn by a pipeline of its own after the standard geometry path has finished with the frame. It is what Trail3DSystem, PathDisplaySystem and DebugDrawSystem are built on - the three shipping systems whose content is rebuilt per frame. (Particles do not use it; they have a billboard pipeline of their own.)

The pieces

import { DynamicMesh } from "@woosh/meep-engine/src/shade/renderer/scene/DynamicMesh.js";
import { DynamicMeshBatch } from "@woosh/meep-engine/src/shade/renderer/scene/DynamicMeshBatch.js";

const batch = new DynamicMeshBatch();          // yours, not the Scene's

const mesh = DynamicMesh.from(geometry, [1, 0.5, 0, 1]);   // Geometry + flat RGBA
batch.add(mesh);

mesh.visible = false;         // hide without losing GPU residency
geometry.needsUpdate = true;  // you say when the vertices changed; nothing watches the arrays

DynamicMesh extends Node3D, so it is posed through transform_local + updateMatrices() like any other node. Its color is a Float32Array RGBA multiplied into whatever per-vertex colour the geometry carries - the alpha is what fades a trail. DynamicMeshBatch exposes add, remove, clear, and the getters meshes (insertion order, which is draw order, because these are blended), count and version.

The vertex layout is fixed

src/shade/renderer/dynamic/DYNAMIC_MESH_VERTEX.js exports DYNAMIC_MESH_VERTEX_STRIDE = 8, DYNAMIC_MESH_VERTEX_POSITION_OFFSET = 0 and DYNAMIC_MESH_VERTEX_COLOR_OFFSET = 4:

FloatsMeaning
[0..2]position x, y, z, object space
[3]reserved - keeps the stride a power of two and leaves room for a UV later
[4..7]colour r, g, b, a

One layout rather than a described one, because a dynamic mesh is drawn by a single pipeline: letting each geometry declare its own attribute set would buy nothing and cost either a pipeline per layout or a shader that branches per vertex.

Your geometry needs a position attribute and an index. A colour attribute is optional; without one, every vertex is uploaded white and the mesh’s flat color is the whole colour. Whatever colour components you do supply (3 or 4) are copied; the rest stay at 1.

import { Geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/Geometry.js";
import { Attribute } from "@woosh/meep-engine/src/shade/renderer/geometry/Attribute.js";
import { StandardAttributes } from "@woosh/meep-engine/src/shade/renderer/geometry/StandardAttributes.js";

const geometry = new Geometry();
geometry.setAttribute(Attribute.from(positions, 3, StandardAttributes.Position));
geometry.setAttribute(Attribute.from(colors, 4, StandardAttributes.Color));
geometry.index = Attribute.from(indices, 1, StandardAttributes.Index);

There is no material and no lighting on this path - one pipeline, position and colour, straight alpha over the finished scene. That is why trails and path display carry no per-object texture, material or lit shading.

Drawing it

GPUDynamicMeshRenderer (src/shade/renderer/dynamic/GPUDynamicMeshRenderer.js) owns the GPU buffers and records the draws. You put one inside a render extension of your own:

import { GPUDynamicMeshRenderer } from "@woosh/meep-engine/src/shade/renderer/dynamic/GPUDynamicMeshRenderer.js";
import { RenderExtension } from "@woosh/meep-engine/src/shade/renderer/extension/RenderExtension.js";
import { FramePhase } from "@woosh/meep-engine/src/shade/renderer/extension/FramePhase.js";
import { SceneColor } from "@woosh/meep-engine/src/shade/renderer/extension/SceneColor.js";
import { ViewTextures } from "@woosh/meep-engine/src/shade/renderer/extension/ViewTextures.js";

class SparkExtension extends RenderExtension {
    name = "sparks";
    phase = FramePhase.AfterTransparency;

    #mesh_renderer = new GPUDynamicMeshRenderer();
    #batch;

    constructor(batch) {
        super();
        this.#batch = batch;
    }

    record(frame) {
        if (this.#batch.count === 0) {
            // nothing to draw, and the frame's colour is left exactly as it was
            return;
        }

        const scene = frame.get(SceneColor);

        scene.color = this.#mesh_renderer.graph_draw({
            graph: frame.graph,
            batch: this.#batch,
            color: scene.color,
            depth: frame.get(ViewTextures).depth,
            camera: frame.view.camera.buffer
        });
    }
}

const extension = engine.graphics.add_extension(new SparkExtension(batch));

graph_draw({graph, batch, color, depth, camera}) returns the colour handle after the draws - thread it back into the SceneColor record, as above. Only the colour is threaded back, because that is what an injected pass is allowed to replace.

Opaque or blended

GPUDynamicMeshRenderer.opaque chooses between the two modes, and it is a property of the pass, not of a mesh - the whole batch moves together:

opaqueBehaviour
false (default)Blended, depth-tested, depth never written. What effects want
trueNo blending; the pass writes the depth it is given

Set it before the first draw. Changing it later rebuilds the pipeline, which is cheap but not free. The true case exists for a solid surface with no meshlet build behind it - geometry generated on the fly, or decoded out of some other container - which has no depth ordering of its own otherwise, and no CPU sort can give it one: a painter’s order over triangles is wrong wherever two of them interpenetrate.

Residency

Residency is keyed on the geometry, not on batch membership. A mesh that blinks off for a frame, or is removed and re-added, keeps its buffers - which is also why a system should write into the geometry it already holds rather than building a fresh one each frame, or it strands a vertex buffer per frame. A geometry that is gone for good should say so:

mesh_renderer.release(geometry);   // -> boolean, whether anything was held

// on shutdown
engine.graphics.remove_extension(extension);
mesh_renderer.destroy();           // frees every geometry still held

Upload happens inside the pass: every visible mesh’s geometry whose version has moved is re-interleaved into the fixed layout and written before the draws are executed.


Occlusion is not an API

Occlusion culling is GPU-driven inside the rasterizer, and the Hi-Z pyramid is internal to Shade. There is no occlusion query to issue, no readback to drive from a post-render hook, and nothing to size or register.

Where to go next

  • Rendering overview - what Shade is, the GraphicsEngine facade, and the six frame phases.
  • Render extensions - the mechanism every system on this page registers through.
  • Trails - Trail3D, the shipped consumer of the dynamic-mesh path.
  • Meshes & materials - the standard path a dynamic mesh is the exception to.