Rendering

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

EffectKey classesIntegration point
Decals (Forward+)Decal, FPDecalSystemECS component + Forward+ cluster
Outlines / highlightsHighlight, HighlightDefinition, OutlineRenderPlugin, OutlineRenderer, MeshHighlightSystemECS component + compositing layer
Ribbons / trailsRibbonX, RibbonXPlugin, RibbonMaterialX, RibbonXFixedPhysicsSimulatorManual geometry + plugin
Camera shakeCameraShake, CameraShakeBehavior, CameraShakeTraumaBehaviorBehavior tree
Hi-Z occlusion cullingHierarchicalZBuffer, BatchOcclusionQueryPost-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:

PropertyTypeNotes
uristringAsset URL — loaded as GameAssetType.Image
prioritynumberControls draw order for overlapping decals
colorColorMultiplied 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):

  1. 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).
  2. Dilation pass (makeDilationShader) — the ID texture is dilated outward by __outline_offset pixels (default: 3 px) using one or more full-screen passes.
  3. Color decode pass (HighlightDecodeShader) — converts IDs to RGBA using a per-definition color lookup texture.
  4. 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):

AttributeShader nameNotes
World positionpositionCentre-line point
Previous positionpreviousUsed for camera-facing expansion
Next positionnextUsed for camera-facing expansion
Side offsetoff0 = left vertex, 1 = right vertex
Color (RGB)colorPer-point color
AlphaalphaPer-point opacity
ThicknessthicknessHalf-width of the strip at this point
UV offsetuv_offsetScrolling along the ribbon length
AgeageSeconds 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.