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.
| Effect | System | Components | Draws at |
|---|---|---|---|
| Decals | DecalSystem(graphics, assets) | Decal, Transform64 | AfterGBuffer, after the terrain |
| Outlines / highlights | HighlightOutlineSystem(graphics) + a source system per kind | Highlight + SGMesh, Highlight + ShadedGeometry | AfterTransparency |
| Trails | Trail3DSystem(graphics) | Trail3D, Transform64 | AfterTransparency |
| Camera shake | CameraShakeBehavior, CameraShakeTraumaBehavior | - | not a render pass at all |
| Your own geometry | GPUDynamicMeshRenderer 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:
| Field | Type | Notes |
|---|---|---|
uri_albedo | string | Base colour; its alpha is the decal’s coverage |
uri_normal | string | Tangent-space normal, applied in the decal’s own frame |
uri_orm | string | glTF packing: G is roughness, B is metalness. R (occlusion) is deliberately ignored, so an authored glTF ORM texture drops in unmodified |
uri_emissive | string | Greyscale mask, multiplied by emissive_color and emissive_intensity |
uri | string | Deprecated. It shadows uri_albedo while both are set, so content authored with it keeps working |
roughness | number | Multiplies whatever uri_orm gives; default 1, so a decal with no ORM map is simply this value |
metalness | number | Same, default 0 |
emissive_color | Color | Default white |
emissive_intensity | number | Zero by default, so a decal that does not set it is non-emissive. Values above one are the point of it being a float |
priority | number | Draw order when decals overlap |
color | Color | Premultiplied 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 = 4095decals survive culling in a frame, andDECAL_CLUSTER_LOCAL_LIMIT = 32fit 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 system | Tuple | Constructor | Finds 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
- 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.
- A primitive needs its own source system. A
Highlighton a bareShadedGeometrydraws nothing unlessShadedGeometryHighlightSystemis 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:
| Floats | Meaning |
|---|---|
[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:
opaque | Behaviour |
|---|---|
false (default) | Blended, depth-tested, depth never written. What effects want |
true | No 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
GraphicsEnginefacade, 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.