Effects
Per-object rendering effects available in Meep — decals, outlines, ribbons/trails, camera shake, and Hi-Z occlusion culling.
Meep ships several screen-space and geometry-based rendering effects as first-class ECS systems and plugins. Each is opt-in: you add the system or plugin, attach a component, and the engine handles the rest.
Feature summary
| Effect | Key classes | Integration point |
|---|---|---|
| Decals (Forward+) | Decal, FPDecalSystem | ECS component + Forward+ cluster |
| Outlines / highlights | Highlight, HighlightDefinition, OutlineRenderPlugin, OutlineRenderer, MeshHighlightSystem | ECS component + compositing layer |
| Ribbons / trails | RibbonX, RibbonXPlugin, RibbonMaterialX, RibbonXFixedPhysicsSimulator | Manual geometry + plugin |
| Camera shake | CameraShake, CameraShakeBehavior, CameraShakeTraumaBehavior | Behavior tree |
| Hi-Z occlusion culling | HierarchicalZBuffer, BatchOcclusionQuery | Post-render pass |
Decals
Decals are axis-aligned projection volumes rendered inside the Forward+ clustered lighting pipeline. The cluster texture stores a decal list per screen-space tile alongside the light list, so decals are looked up in exactly one texture fetch per fragment, with no additional draw calls.
Component: Decal (from src/engine/graphics/ecs/decal/v2/Decal.js).
import { Decal } from "@woosh/meep-engine/src/engine/graphics/ecs/decal/v2/Decal.js";
import { FPDecalSystem } from "@woosh/meep-engine/src/engine/graphics/ecs/decal/v2/FPDecalSystem.js";
// Register the system once
em.addSystem(new FPDecalSystem(engine));
// Attach a decal to an entity that also has a Transform
entity.add(Decal.fromJSON({
uri: "data/textures/decals/blood_splat.png",
priority: 0, // draw order when decals overlap
color: "#ffffff"
}));
Decal properties:
| Property | Type | Notes |
|---|---|---|
uri | string | Asset URL — loaded as GameAssetType.Image |
priority | number | Controls draw order for overlapping decals |
color | Color | Multiplied into the loaded texture |
The entity’s Transform sets the projection volume. Scale the transform to size the decal box; the default unit cube maps to a 1 m³ projection.
FPDecalSystem maintains a BVH over all active decals and exposes raycast(...) and queryOverlapFrustum(...) for gameplay queries against the decal set.
Outlines and highlights
Per-object edge outlines are driven by the Highlight ECS component and rendered by OutlineRenderer (in src/engine/graphics/ecs/highlight/renderer/OutlineRenderer.js). The pass runs as a compositing layer at CompositingStages.POST_TRANSPARENT.
Render pipeline (verified in OutlineRenderer):
- ID pass — each highlighted mesh is re-rendered into a single-channel R8 render target using a minimal shader that writes an object ID (up to 256 distinct highlight definitions).
- Dilation pass (
makeDilationShader) — the ID texture is dilated outward by__outline_offsetpixels (default: 3 px) using one or more full-screen passes. - Color decode pass (
HighlightDecodeShader) — converts IDs to RGBA using a per-definition color lookup texture. - Blur pass (
makeGaussianBlurShader, 5-sample, distance 5) — softens the result to produce a glowing edge, masked to the original silhouette.
The final texture is blended onto the scene via the compositing layer with BlendingType.MultiplyAdd.
Usage:
import { MeshHighlightSystem } from "@woosh/meep-engine/src/engine/graphics/ecs/highlight/system/MeshHighlightSystem.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";
em.addSystem(new MeshHighlightSystem(engine)); // registers OutlineRenderPlugin automatically
// Highlight an entity red
const h = new Highlight();
h.add(HighlightDefinition.rgba(1, 0.1, 0.1, 1.0));
entity.add(h);
Highlight holds a list of HighlightDefinition objects. Each definition carries a Color (RGBA). Multiple definitions on one entity blend. Up to 256 distinct definitions are supported per frame.
Ribbons and trails
Ribbons are fixed-length, screen-facing geometry strips, suited to projectile trails, slash effects, and particle tails. The geometry lives in RibbonX (src/engine/graphics/trail/x/RibbonX.js).
Geometry model: each ribbon is a ring buffer of count points. Each point contributes two vertices, so an N-point ribbon produces (N-1) quads. The GPU buffer is DynamicDrawUsage; only the modified InterleavedBuffer is marked for re-upload each frame.
Per-vertex attributes (interleaved, from ribbon_attributes_spec):
| Attribute | Shader name | Notes |
|---|---|---|
| World position | position | Centre-line point |
| Previous position | previous | Used for camera-facing expansion |
| Next position | next | Used for camera-facing expansion |
| Side offset | off | 0 = left vertex, 1 = right vertex |
| Color (RGB) | color | Per-point color |
| Alpha | alpha | Per-point opacity |
| Thickness | thickness | Half-width of the strip at this point |
| UV offset | uv_offset | Scrolling along the ribbon length |
| Age | age | Seconds since this point was written |
Simulator: RibbonXFixedPhysicsSimulator (src/engine/graphics/trail/x/simulator/RibbonXFixedPhysicsSimulator.js) ages each point per tick, reduces alpha linearly to 0 over max_age seconds, and scrolls the UV offset.
Plugin: RibbonXPlugin manages a material cache and a shared resolution uniform that is kept in sync with the viewport size. Call plugin.obtain_material_reference(spec) where spec is a RibbonXMaterialSpec with an optional diffuse texture URL.
import { RibbonX } from "@woosh/meep-engine/src/engine/graphics/trail/x/RibbonX.js";
import { RibbonXPlugin } from "@woosh/meep-engine/src/engine/graphics/trail/x/RibbonXPlugin.js";
import { RibbonXMaterialSpec } from "@woosh/meep-engine/src/engine/graphics/trail/x/RibbonXMaterialSpec.js";
const plugin = new RibbonXPlugin();
await engine.plugins.install(plugin);
const ribbon = new RibbonX();
ribbon.setCount(32); // 32 points → 31 quads
ribbon.buildGeometry();
const spec = new RibbonXMaterialSpec();
spec.diffuse = "data/textures/trail.png"; // optional
const matRef = plugin.obtain_material_reference(spec);
// Each frame: rotate the ring buffer, set the new head position
ribbon.rotate();
ribbon.setPointPosition(ribbon.getHeadIndex(), x, y, z);
ribbon.setPointThickness(ribbon.getHeadIndex(), 0.1);
The material (RibbonMaterialX) is transparent, depth-write off, DoubleSide.
Camera shake
Camera shake is implemented as a pair of Behavior subclasses in src/engine/graphics/camera/.
CameraShake (CameraShake.js) generates six independent simplex noise streams — yaw, pitch, roll, and X/Y/Z offset — seeded from a fixed value for reproducibility. Calling shake.read(value, time, offset, rotation) returns instantaneous offset and Euler-angle rotation scaled by value (0–1).
CameraShakeBehavior (CameraShakeBehavior.js) wraps a CameraShake and updates a TopDownCameraController’s target, pitch, yaw, and roll every tick. Limits are set at construction time:
import { CameraShakeBehavior } from "@woosh/meep-engine/src/engine/graphics/camera/CameraShakeBehavior.js";
import { CameraShakeTraumaBehavior } from "@woosh/meep-website/meep-website/examples-src/first-person/node_modules/@woosh/meep-engine/src/engine/graphics/camera/CameraShakeTraumaBehavior.js";
const shakeBehavior = new CameraShakeBehavior({
controller,
maxPitch: 0.05,
maxYaw: 0.05,
maxRoll: 0.02,
maxOffsetX: 0.1,
maxOffsetY: 0.1,
});
CameraShakeTraumaBehavior (CameraShakeTraumaBehavior.js) 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 passed as strength to the underlying CameraShakeBehavior. 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 behaviors are based on Squirrel Eiserloh’s 2016 GDC talk “Math for Game Programmers: Juicing Your Cameras With Math”.
Hierarchical-Z (Hi-Z) occlusion culling
HierarchicalZBuffer (src/engine/graphics/render/visibility/hiz/buffer/HierarchicalZBuffer.js) builds a GPU mip pyramid from the scene’s depth buffer each frame. Each mip is a R32F render target; build_mips() runs a ReductionShader that takes the max of a 2×2 texel block, so coarser mips store the furthest depth across their region. This is a conservative test: if the depth of a bounding volume’s nearest face is behind the coarsest mip that fits the volume, the volume is definitely occluded.
BatchOcclusionQuery (src/engine/graphics/render/visibility/hiz/query/BatchOcclusionQuery.js) tests up to setSize(N) AABBs against the Hi-Z pyramid in a single full-screen shader pass. Each query element packs an AABB (6 floats) and a 4×4 projection matrix (16 floats) into a DataTexture; the query shader projects each AABB, picks the appropriate mip level, and writes a 0/1 result into an output WebGLRenderTarget. Results are read back with readResultTexture(renderer, uint8Array).
import { HierarchicalZBuffer } from "@woosh/meep-engine/src/engine/graphics/render/visibility/hiz/buffer/HierarchicalZBuffer.js";
import { BatchOcclusionQuery } from "@woosh/meep-engine/src/engine/graphics/render/visibility/hiz/query/BatchOcclusionQuery.js";
import { StandardFrameBuffers } from "@woosh/meep-engine/src/engine/graphics/StandardFrameBuffers.js";
const hiz = new HierarchicalZBuffer();
const depthTex = graphics.frameBuffers
.getById(StandardFrameBuffers.ColorAndDepth)
.getRenderTarget().depthTexture;
hiz.input = depthTex;
hiz.renderer = graphics.renderer;
const query = new BatchOcclusionQuery();
query.setSize(100);
graphics.on.postRender.add(() => {
hiz.set_resolution(depthTex.image.width, depthTex.image.height);
hiz.build_mip_chain();
hiz.build_mips();
// fill query.setElement(i, aabb, projectionMatrix) for each candidate
query.execute(renderer, hiz);
const results = new Uint8Array(query.getResultTexture().image.width *
query.getResultTexture().image.height * 4);
query.readResultTexture(renderer, results);
// results[i*4] === 1 if visible, 0 if occluded
});
The Hi-Z system is a standalone utility; it is not wired into any built-in system by default. Connect it in your post-render hook as shown above.