// Highlight — a Meep example for outline highlighting.
//
// A 3×3 grid of torus knots. The centre one is bare; the other eight each
// wear a different Highlight configuration — solid colors, a dimmed
// low-opacity outline, a stacked pair of definitions, a pulse, and a hue
// cycle. Everything you can do with the component, side by side.
//
// How highlighting works:
//
// - `Highlight` is a component holding a list of `HighlightDefinition`s.
// Each definition is just an RGBA color — alpha is the definition's
// opacity, and multiple definitions composite in order (each lerps the
// outline color by its alpha), so a stack of definitions reads as
// layered tints. Game code typically stacks one definition per *reason*
// (selected, targeted, on-fire...) and removes them independently.
// - `ShadedGeometryHighlightSystem` draws the outlines for any entity
// carrying Highlight + ShadedGeometry. (For aggregated SGMesh content
// there's `SGMeshHighlightSystem`, which propagates a parent's Highlight
// down to its mesh children — see the entity-stress-test example.)
//
// Definitions are LIVE data: mutate a color, change an alpha, add or remove
// an element — the outline follows on the next frame. Two of the knots
// below exploit that for pulse / hue-cycle effects.
//
// Sections:
// §1 Tuning constants
// §2 Engine bootstrap
// §3 Ground
// §4 The knots 3×3 grid, one Highlight config each
// §5 Per-frame spin the knots, animate two highlights, HUD
import * as THREE from "three";
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.js";
import { ShadedGeometry } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometry.js";
import { ShadedGeometrySystem } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometrySystem.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";
import {
ShadedGeometryHighlightSystem
} from "@woosh/meep-engine/src/engine/graphics/ecs/highlight/system/ShadedGeometryHighlightSystem.js";
import Vector3 from "@woosh/meep-engine/src/core/geom/Vector3.js";
// ─── §1 Tuning constants ────────────────────────────────────────────────────
const SPACING = 3.4; // grid pitch, metres
const KNOT_Y = 1.35; // hover height above the floor
const SPIN_RATE = 0.4; // rad/s — slow tumble so the outlines read
// from changing silhouettes
const BODY_COLOR = 0x9aa6b2; // neutral steel so the outlines carry the color
const CENTER_COLOR = 0x4ef0a8; // the bare knot gets the brand green instead
// ─── §2 Engine bootstrap ────────────────────────────────────────────────────
const engine = await EngineHarness.bootstrap({
configuration: (config, engine) => {
config.addSystem(new ShadedGeometrySystem(engine));
// The outline pass: draws every entity that has Highlight + ShadedGeometry.
config.addSystem(new ShadedGeometryHighlightSystem(engine));
},
});
await EngineHarness.buildBasics({
engine,
enableTerrain: false,
enableWater: false,
enableLights: true,
enableShadows: true,
shadowmapResolution: 2048,
focus: new Vector3(0, 0.6, 0),
distance: 13,
pitch: 0.75,
yaw: 0,
cameraFarDistance: 200,
showFps: false,
});
const ecd = engine.entityManager.dataset;
ecd.registerComponentType(Highlight);
// ─── §3 Ground ──────────────────────────────────────────────────────────────
{
const t = new Transform();
t.position.set(0, -0.25, 0);
new Entity()
.add(t)
.add(ShadedGeometry.from(
new THREE.BoxGeometry(3 * SPACING + 4, 0.5, 3 * SPACING + 4),
new THREE.MeshStandardMaterial({ color: 0x161c24, roughness: 1 }),
))
.build(ecd);
}
// ─── §4 The knots ───────────────────────────────────────────────────────────
//
// Each grid cell gets a torus knot (with its own (p, q) winding, for variety)
// and a `highlight` factory: null for the bare centre, otherwise a function
// returning a configured Highlight. The animated ones also register an
// `animate(t)` callback that mutates their definitions every frame.
const animated = []; // { animate(t) } entries serviced in §5
// Two-element stack: a solid red base tinted by a half-opacity yellow layer.
// Definitions composite in order — this is the "selected AND targeted" idiom.
function stacked() {
const h = new Highlight();
h.add(HighlightDefinition.rgba(1.0, 0.25, 0.2, 1)); // base: red
h.add(HighlightDefinition.rgba(1.0, 0.9, 0.2, 0.5)); // layer: 50% yellow
return h;
}
// Pulse: a green definition whose OPACITY breathes. Opacity is just the
// definition color's alpha — write it and the outline follows.
function pulsing() {
const def = HighlightDefinition.rgba(0.31, 0.94, 0.66, 1);
const h = new Highlight();
h.add(def);
animated.push({ animate: (t) => def.color.setA(0.55 + 0.45 * Math.sin(t * 3)) });
return h;
}
// Hue cycle: rewrite the definition's RGB every frame.
function cycling() {
const def = HighlightDefinition.rgba(1, 0, 0, 1);
const h = new Highlight();
h.add(def);
animated.push({
animate: (t) => {
const third = (2 * Math.PI) / 3;
def.color.set(
0.5 + 0.5 * Math.sin(t),
0.5 + 0.5 * Math.sin(t + third),
0.5 + 0.5 * Math.sin(t + 2 * third),
1,
);
},
});
return h;
}
// Reading order, top-left → bottom-right. (p, q) are the knot windings.
const CELLS = [
{ p: 2, q: 3, highlight: () => Highlight.fromOne(0.31, 0.94, 0.66, 1) }, // solid green
{ p: 3, q: 4, highlight: () => Highlight.fromOne(1.0, 0.25, 0.2, 1) }, // solid red
{ p: 1, q: 3, highlight: () => Highlight.fromOne(0.35, 0.55, 1.0, 1) }, // solid blue
{ p: 2, q: 5, highlight: () => Highlight.fromOne(1, 1, 1, 0.35) }, // 35% white — a dim outline
{ p: 2, q: 1, highlight: null }, // centre: bare
{ p: 3, q: 2, highlight: stacked }, // red + 50% yellow stack
{ p: 4, q: 3, highlight: pulsing }, // opacity pulse
{ p: 3, q: 5, highlight: cycling }, // hue cycle
{ p: 5, q: 2, highlight: () => Highlight.fromOne(1, 1, 1, 1) }, // solid white
];
const knotMaterial = new THREE.MeshStandardMaterial({ color: BODY_COLOR, roughness: 0.45, metalness: 0.6 });
const centerMaterial = new THREE.MeshStandardMaterial({ color: CENTER_COLOR, roughness: 0.45, metalness: 0.4 });
const spinners = []; // transforms to tumble in §5
for (let i = 0; i < CELLS.length; i++) {
const cell = CELLS[i];
const col = i % 3;
const row = (i / 3) | 0;
const t = new Transform();
t.position.set((col - 1) * SPACING, KNOT_Y, (row - 1) * SPACING);
spinners.push({ transform: t, phase: i * 0.7 });
const entity = new Entity()
.add(t)
.add(ShadedGeometry.from(
new THREE.TorusKnotGeometry(0.62, 0.17, 128, 16, cell.p, cell.q),
cell.highlight === null ? centerMaterial : knotMaterial,
));
if (cell.highlight !== null) {
entity.add(cell.highlight());
}
entity.build(ecd);
}
// ─── §5 Per-frame: spin the knots, animate two highlights, HUD ─────────────
const fpsEl = document.getElementById("fps");
document.getElementById("knots").textContent = String(CELLS.length);
const spinAxis = new Vector3(0.3, 1, 0.2).normalize();
let time = 0;
let fpsWindow = 0, fpsFrames = 0;
let lastFrameMs = performance.now();
engine.graphics.on.postRender.add(() => {
const nowMs = performance.now();
const dt = Math.min((nowMs - lastFrameMs) / 1000, 0.1);
lastFrameMs = nowMs;
time += dt;
// Tumble every knot around a shared tilted axis, each at its own phase.
for (let i = 0; i < spinners.length; i++) {
const s = spinners[i];
s.transform.rotation.fromAxisAngle(spinAxis, SPIN_RATE * time + s.phase);
}
// Live highlight mutations: pulse and hue cycle.
for (let i = 0; i < animated.length; i++) {
animated[i].animate(time);
}
// HUD
fpsWindow += dt;
fpsFrames++;
if (fpsWindow >= 0.5) {
fpsEl.textContent = (fpsFrames / fpsWindow).toFixed(0);
fpsWindow = 0;
fpsFrames = 0;
}
});
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Highlight · Meep</title>
<meta name="robots" content="noindex">
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
width: 100%; height: 100%;
overflow: hidden;
background: #07090c;
color: #e6edf3;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.panel {
position: fixed;
z-index: 100;
background: rgba(7, 9, 12, 0.72);
border: 1px solid #1f2731;
border-radius: 10px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 12px 32px rgba(0,0,0,0.4);
}
.hud {
top: 1rem; left: 1rem;
padding: 0.8rem 1rem;
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 0.82rem;
line-height: 1.7;
color: #9aa5b1;
min-width: 180px;
}
.hud .label {
color: #6b7785;
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.65rem;
margin-right: 0.5rem;
}
.hud .value { color: #4ef0a8; }
.legend {
bottom: 1rem; left: 1rem;
padding: 0.8rem 1rem;
font-size: 0.82rem;
line-height: 1.55;
max-width: 440px;
color: #9aa5b1;
}
.legend strong { color: #e6edf3; }
</style>
</head>
<body>
<div class="panel hud">
<div><span class="label">fps</span><span class="value" id="fps">--</span></div>
<div><span class="label">knots</span><span class="value" id="knots">--</span></div>
</div>
<div class="panel legend">
Eight torus knots each wear a different <strong>Highlight</strong> outline —
four solid colors, a dim 35% outline, a two-definition <strong>stack</strong>,
an opacity <strong>pulse</strong>, and a <strong>hue cycle</strong>. The
centre knot is bare. Drag to orbit · scroll to zoom.
</div>
<script type="module" src="./src/main.js"></script>
</body>
</html>
{
"title": "Highlight outlines",
"description": "A 3×3 grid of torus knots, eight of them wearing a different Highlight configuration each — solid colors, a dim outline, stacked definitions, and live pulse and hue-cycle mutations. The centre knot is bare.",
"category": "Rendering",
"status": "live",
"order": 6,
"tags": ["rendering", "highlight", "outline", "selection", "ecs"],
"sourceHint": "examples-src/highlight/",
"demoUrl": "/examples/highlight/demo.html",
"defaultFile": "src/main.js"
}
{
"name": "@meep-examples/highlight",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "A 3x3 grid of torus knots showing every Highlight outline configuration — solid colors, opacity, stacked definitions, and live pulse/cycle/blink mutations.",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@woosh/meep-engine": "2.158.0",
"three": "0.136.0"
},
"devDependencies": {
"@rollup/plugin-strip": "^3.0.4",
"vite": "^8.0.13"
}
}
# Highlight outlines
A 3×3 grid of torus knots. The centre one is bare; the other eight each wear a
different `Highlight` configuration — four solid colors, a dim 35%-opacity
outline, a two-definition stack, an opacity pulse, and a hue cycle.
What it demonstrates:
- `Highlight` — a component holding a list of `HighlightDefinition`s (each
just an RGBA color; alpha is opacity). Definitions composite in order, so
game code can stack one per *reason* (selected, targeted, on-fire…) and
remove them independently.
- `ShadedGeometryHighlightSystem` — draws outlines for any entity carrying
`Highlight + ShadedGeometry`. (Aggregated `SGMesh` content uses
`SGMeshHighlightSystem` to propagate a parent's Highlight to mesh children —
see the entity-stress-test example.)
- Definitions are live data: mutate a color or alpha, or add/remove an
element, and the outline follows on the next frame — the pulse and cycle
knots are each a three-line `animate(t)` callback.
## Run
```sh
npm install
npm run dev
```
## Build
`npm run build` emits the static demo into `public/examples/highlight/`.
import { defineConfig } from "vite";
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { resolve, dirname } from "node:path";
import strip from "@rollup/plugin-strip";
const __dirname = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
{
// Copy the committed source thumbnail into the generated gallery folder.
// public/examples/<id>/ is build output (gitignored); thumbnail.png is
// kept in source here and copied through on every build so the gallery
// (src/data/examples.ts) can resolve it.
name: "copy-thumbnail",
apply: "build",
closeBundle() {
const thumb = resolve(__dirname, "thumbnail.png");
const dst = resolve(__dirname, "../../public/examples/highlight");
if (existsSync(thumb)) {
mkdirSync(dst, { recursive: true });
copyFileSync(thumb, resolve(dst, "thumbnail.png"));
}
},
},
],
base: "./",
build: {
outDir: resolve(__dirname, "../../public/examples/highlight"),
emptyOutDir: false,
rollupOptions: {
input: resolve(__dirname, "demo.html"),
plugins: [
{
// this will remove all assert statements from the production build
...strip(),
apply: 'build'
}
],
},
target: "es2022",
},
});