// Highlight — a Meep example for outline highlighting.
//
// A 3×3 grid of fantasy props. 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 definitions composite `over` each other in list order into
// the single premultiplied color the outline is drawn in. Game code
// typically stacks one definition per *reason* (selected, targeted,
// on-fire...) and removes them independently.
// - Three systems draw them, split by responsibility. `HighlightOutlineSystem`
// owns the pass: it observes no entities, holds the lookup table and does
// the drawing. Two source systems tell it what is highlighted, one per kind
// of thing - `SGMeshHighlightSystem` for a **loaded model** (`Highlight +
// SGMesh`, resolved through the mesh system to the rows the model occupies)
// and `ShadedGeometryHighlightSystem` for a **primitive built in code**
// (`Highlight + ShadedGeometry`, which is exactly one row). One pass, two
// sources: two passes would each read the whole visibility buffer and the
// second would paint over the first.
//
// The front row of this demo is primitives and the grid behind it is models,
// so both paths are on screen at once.
//
// What an outline *is* under Shade, and how it shows:
//
// **An outline is a neighbourhood test on the visibility buffer.** Shade
// already writes a per-pixel mesh id while drawing the frame, so the pass
// packs one rgba8 color per highlighted mesh row, then walks a 3-pixel disc
// around every pixel and paints where a highlighted neighbour is found. No
// second geometry pass, no dilate-and-blur ping-pong, no id target.
//
// The cost of that: **a highlighted object no longer shows through what
// stands in front of it.** The old renderer drew the highlight mask with the
// depth buffer cleared, so a unit behind a building was outlined in full;
// the visibility buffer knows only what was actually rasterized, so an
// object is outlined where it can be seen and nowhere else. Orbit the camera
// until one prop passes behind another and watch the far outline get eaten.
// The outline also sits strictly *outside* the silhouette — a pixel that is
// itself the highlighted mesh is left alone, so the mark never paints over
// the thing it marks.
//
// Definitions are LIVE data: mutate a color, change an alpha, add or remove
// an element — the outline follows on the next frame (the table is repacked
// every frame, precisely because highlights animate). Two of the props below
// exploit that for pulse / hue-cycle effects.
//
// Sections:
// §1 Tuning constants
// §2 Engine bootstrap loaders, MeshSystem, the outline pass + its two sources
// §3 Ground
// §4 The props 3×3 grid of models, one Highlight config each
// §4b The primitives a front row built in code, highlighted the same way
// §5 Per-frame turn the props, animate two highlights, HUD
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import { Light } from "@woosh/meep-engine/src/engine/graphics/ecs/light/Light.js";
import { LightType } from "@woosh/meep-engine/src/engine/graphics/ecs/light/LightType.js";
import { GameAssetType } from "@woosh/meep-engine/src/engine/asset/GameAssetType.js";
import { load_model_scene_bundle } from "@woosh/meep-engine/src/engine/asset/load_model_scene_bundle.js";
import { ArrayBufferLoader } from "@woosh/meep-engine/src/engine/asset/loaders/ArrayBufferLoader.js";
import {
GLTFSceneBundleAssetLoader
} from "@woosh/meep-engine/src/engine/asset/loaders/GLTFSceneBundleAssetLoader.js";
import {
ImageBitmapAssetLoader
} from "@woosh/meep-engine/src/engine/asset/loaders/image/ImageBitmapAssetLoader.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { ParentEntitySystem } from "@woosh/meep-engine/src/engine/ecs/parent/ParentEntitySystem.js";
import {
TransformAttachmentSystem
} from "@woosh/meep-engine/src/engine/ecs/transform-attachment/TransformAttachmentSystem.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import { t64_announce_change } from "@woosh/meep-engine/src/engine/ecs/transform/t64_announce_change.js";
import { Name } from "@woosh/meep-engine/src/engine/ecs/name/Name.js";
import {
TransformAttachment
} from "@woosh/meep-engine/src/engine/ecs/transform-attachment/TransformAttachment.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 { SGMesh } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/aggregate/SGMesh.js";
import { ShadedGeometry } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometry.js";
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 { ShadedGeometrySystem } from "@woosh/meep-engine/src/engine/graphics3/ShadedGeometrySystem.js";
import { make_box_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_box_geometry.js";
import {
make_octahedron_geometry
} from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_octahedron_geometry.js";
import {
make_torus_knot_geometry
} from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_torus_knot_geometry.js";
import {
meshlet_geometry_build_from_geometry
} from "@woosh/meep-engine/src/shade/renderer/geometry/meshlet_geometry_build_from_geometry.js";
import { StandardShadeMaterial } from "@woosh/meep-engine/src/shade/renderer/material/StandardShadeMaterial.js";
import { Color } from "@woosh/meep-engine/src/core/color/Color.js";
import Vector3 from "@woosh/meep-engine/src/core/geom/Vector3.js";
import Quaternion from "@woosh/meep-engine/src/core/geom/Quaternion.js";
// ─── §1 Tuning constants ────────────────────────────────────────────────────
const SPACING = 3.8; // grid pitch, metres
const TURN_RATE = 0.35; // rad/s — slow turn so the outlines read
// from changing silhouettes
const GROUND_COLOR = "#161c24"; // near-black floor; the outlines carry the color
// One prop per grid cell: `PROP_URLS[(col + row + 2) % 3]`, a Latin square, so
// every row and every column holds one of each. Neighbouring outlines then sit
// on different silhouettes, and no column stacks three bookcases in front of
// each other, where a tall one would eat the outline of the prop behind it.
//
// All three are chosen for their silhouettes: a chair, a slumped bag and a tall
// bookcase read as three different outlines at a glance, which is the whole
// point of a demo about outlines.
const PROP_URL_BASE = "./models/fantasy-props/";
const PROP_URLS = [
"chair.gltf",
"bag-big.gltf",
"bookcase.gltf",
].map((p) => PROP_URL_BASE + p);
// ─── §2 Engine bootstrap ────────────────────────────────────────────────────
//
// Highlighting a model means three systems, not one:
//
// - `MeshSystem` turns an `SGMesh`'s URL into a subtree of entities, one
// `ShadedGeometry` per glTF primitive. It needs the same Shade scene every
// other drawing system was given — `EngineHarness.shadeScene(engine)` hands
// out one per engine — and a loader function, so the three models are
// fetched once between all nine placements.
// - `ShadedGeometrySystem` draws those primitives (and the ground below).
// - `SGMeshHighlightSystem` is handed the mesh system, not the scene: it asks it
// which meshes an entity has before it can say which rows to outline.
//
// `ParentEntitySystem` + `TransformAttachmentSystem` are what tie the model's
// subtree to the prop entity — lifetime and pose. Without the second one every
// prop's geometry sits at the model's own origin instead of its grid cell.
let meshSystem = null;
const engine = await EngineHarness.bootstrap({
configuration: (config, engine) => {
const scene = EngineHarness.shadeScene(engine);
// glTF yields a Shade `SceneBundle`. Its `.bin` buffers and its images
// are requested back through the asset manager, so those loaders have
// to be present too — without `ImageBitmapAssetLoader` the props load
// as untextured geometry.
const gltfLoader = new GLTFSceneBundleAssetLoader();
config.addLoader(GameAssetType.ModelGLTF, gltfLoader);
config.addLoader(GameAssetType.ModelGLTF_JSON, gltfLoader);
config.addLoader(GameAssetType.ArrayBuffer, new ArrayBufferLoader());
config.addLoader(GameAssetType.ImageBitmap, new ImageBitmapAssetLoader());
meshSystem = new MeshSystem(
engine.graphics,
scene,
(url) => load_model_scene_bundle(engine.assetManager, url),
);
config.addSystem(meshSystem);
config.addSystem(new ShadedGeometrySystem(engine.graphics, scene));
config.addSystem(new ParentEntitySystem());
config.addSystem(new TransformAttachmentSystem());
// The outline pass, and the two systems that tell it what to outline:
// one reads models, the other reads primitives. Both hand their entries
// to the same pass, which is what keeps the outline one read of the
// visibility buffer no matter how many kinds of thing are highlighted.
const highlightOutline = new HighlightOutlineSystem(engine.graphics);
config.addSystem(highlightOutline);
config.addSystem(new SGMeshHighlightSystem(highlightOutline, meshSystem));
config.addSystem(new ShadedGeometryHighlightSystem(highlightOutline));
},
});
await EngineHarness.buildBasics({
engine,
enableTerrain: false,
enableWater: false,
enableLights: true,
enableShadows: true,
shadowmapResolution: 2048,
focus: new Vector3(0, 0.7, -0.35 * SPACING),
distance: 19,
pitch: 0.95,
yaw: 0,
cameraFarDistance: 200,
showFps: false,
});
// Exposure and key light, the same in every demo on this site. Shade's automatic
// exposure is off: these scenes are lit to be looked at, not adapted to, and an
// adapting frame makes two screenshots of the same demo disagree. That leaves
// the light doing the work - and a directional light's intensity is read as lux,
// where the harness's default of 0.9 is a value from the three.js era that only
// ever looked lit because auto-exposure opened up to meet it.
engine.graphics.renderer.feature_automatic_exposure_enabled = false;
engine.entityManager.dataset.traverseEntities([Light], (light) => {
if (light.type.getValue() === LightType.DIRECTION) {
light.intensity.set(6);
}
});
const ecd = engine.entityManager.dataset;
ecd.registerComponentType(Highlight);
// ─── §3 Ground ──────────────────────────────────────────────────────────────
//
// A plain box, drawn by ShadedGeometrySystem. Clustering a geometry runs the
// meshoptimizer WASM module, which is only ready once the engine has
// bootstrapped — hence built here rather than at module scope.
{
const t = new Transform64();
t.setTranslation(0, -0.25, -0.5 * SPACING);
const groundMaterial = new StandardShadeMaterial();
groundMaterial.diffuse_color.copy(Color.from_sRGB_to_linear(Color.parse(GROUND_COLOR)));
groundMaterial.roughness_factor = 1;
groundMaterial.metallic_factor = 0;
new Entity()
.add(t)
.add(ShadedGeometry.from(
meshlet_geometry_build_from_geometry(make_box_geometry(3 * SPACING + 3, 0.5, 4 * SPACING + 3)),
groundMaterial,
))
.build(ecd);
}
// ─── §4 The props ───────────────────────────────────────────────────────────
//
// Each grid cell gets one `SGMesh` prop 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.
//
// Highlight colors are plain linear RGB in 0..1 — they go into the frame's
// color buffer as they are, before tone mapping, so they are not sRGB hex the
// way a material's `diffuse_color` is.
const animated = []; // { animate(t) } entries serviced in §5
// Two-element stack: a solid red base with a half-opacity yellow layer over it.
// Definitions composite `over` in order — this is the "selected AND targeted"
// idiom, one definition per reason.
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 next frame.
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.
const CELLS = [
{ highlight: () => Highlight.fromOne(0.31, 0.94, 0.66, 1) }, // solid green
{ highlight: () => Highlight.fromOne(1.0, 0.25, 0.2, 1) }, // solid red
{ highlight: () => Highlight.fromOne(0.35, 0.55, 1.0, 1) }, // solid blue
{ highlight: () => Highlight.fromOne(1, 1, 1, 0.35) }, // 35% white — a dim outline
{ highlight: null }, // centre: bare
{ highlight: stacked }, // red + 50% yellow stack
{ highlight: pulsing }, // opacity pulse
{ highlight: cycling }, // hue cycle
{ highlight: () => Highlight.fromOne(1, 1, 1, 1) }, // solid white
];
const turners = []; // transforms to turn 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 Transform64();
t.setTranslation((col - 1) * SPACING, 0, (row - 1) * SPACING);
// SGMesh.fromURL names the model and opts it into shadows. MeshSystem
// loads the file (once per URL) and builds the prop's geometry under it.
const entity = new Entity()
.add(t)
.add(SGMesh.fromURL(PROP_URLS[(col + row + 2) % PROP_URLS.length]));
// The Highlight goes on the prop entity itself — the one carrying SGMesh —
// not on the primitives underneath it. HighlightSystem walks down from
// here, so a model that has not finished loading contributes no rows and
// simply starts being outlined when it arrives.
if (cell.highlight !== null) {
entity.add(cell.highlight());
}
turners.push({ transform: t, entity: entity.build(ecd), phase: i * 0.7 });
}
// ─── §4b The primitives ────────────────────────────────────────────────────
//
// Three shapes built in code rather than loaded from a file: a torus knot, an
// octahedron and a box, each a single `ShadedGeometry` wearing a `Highlight`.
// Nothing else about them is special - the outline arrives through
// `ShadedGeometryHighlightSystem` instead of `SGMeshHighlightSystem`, and the
// pass cannot tell the difference once the entries reach it.
//
// The geometry is built here rather than at module scope because clustering
// runs the meshoptimizer WASM module, which is ready only after bootstrap.
const PRIMITIVE_COLOR = "#8b98a8";
const primitiveMaterial = new StandardShadeMaterial();
primitiveMaterial.diffuse_color.copy(Color.from_sRGB_to_linear(Color.parse(PRIMITIVE_COLOR)));
primitiveMaterial.roughness_factor = 0.4;
primitiveMaterial.metallic_factor = 0.1;
const PRIMITIVES = [
{
geometry: () => make_torus_knot_geometry(0.62, 0.2, 96, 14),
height: 0.95,
highlight: () => Highlight.fromOne(0.31, 0.94, 0.66, 1), // solid green
},
{
geometry: () => make_octahedron_geometry(0.85, 12),
height: 0.95,
highlight: pulsing, // opacity pulse
},
{
geometry: () => make_box_geometry(1.2, 1.2, 1.2),
height: 0.7,
highlight: () => Highlight.fromOne(1, 1, 1, 0.35), // 35% white
},
];
for (let i = 0; i < PRIMITIVES.length; i++) {
const spec = PRIMITIVES[i];
const t = new Transform64();
t.setTranslation((i - 1) * SPACING, spec.height, -1.75 * SPACING);
const entity = new Entity()
.add(t)
.add(ShadedGeometry.from(
meshlet_geometry_build_from_geometry(spec.geometry()),
primitiveMaterial,
))
.add(spec.highlight())
.build(ecd);
turners.push({ transform: t, entity, phase: 2.1 + i * 0.9 });
}
// ─── §5 Per-frame: turn the props, animate two highlights, HUD ─────────────
const fpsEl = document.getElementById("fps");
document.getElementById("props").textContent = String(CELLS.length);
document.getElementById("primitives").textContent = String(PRIMITIVES.length);
let time = 0;
const spin = new Quaternion(); // scratch for the per-frame turn, reused every frame
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;
// Turn everything about its own vertical axis, each at its own phase. A
// model's primitives are separate entities attached to its transform, so
// TransformAttachmentSystem carries the rotation down to them; a
// code-built primitive is the one entity and turns directly. Either way the
// outline, read off the visibility buffer, follows for free.
//
// A Transform64 has no signals: the write is invisible until the entity
// announces it, and everything downstream - the render placement, the
// attached primitives - wakes on that announcement.
for (let i = 0; i < turners.length; i++) {
const s = turners[i];
spin.fromAxisAngle(Vector3.up, TURN_RATE * time + s.phase);
s.transform.setRotation(spin.x, spin.y, spin.z, spin.w);
s.transform.updateMatrix();
t64_announce_change(ecd, s.entity);
}
// 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;
}
});
// A handle for poking at the demo from the console, the same one the other
// examples expose: `__highlight.ecd`, `.turners`, `.PRIMITIVES`, and the
// component classes needed to walk what a loaded model became.
window.__highlight = {
engine, ecd, turners, animated, meshSystem,
components: { Transform64, SGMesh, ShadedGeometry, Highlight, Name, TransformAttachment },
};
<!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">props</span><span class="value" id="props">--</span></div>
<div><span class="label">primitives</span><span class="value" id="primitives">--</span></div>
</div>
<div class="panel legend">
Eight glTF props 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 prop is bare. The front row is built in code rather than loaded, and
is outlined the same way: a <strong>model</strong> and a
<strong>primitive</strong> reach the one outline pass through a source system
each. The outline is a neighbourhood test on the visibility buffer, so a
highlighted thing is outlined only where it is actually visible - orbit until
one passes behind another and watch the far outline get eaten.
Drag to orbit · scroll to zoom.
</div>
<script type="module" src="./src/main.js"></script>
</body>
</html>
{
"title": "Highlight outlines",
"description": "A 3x3 grid of glTF props, eight of them wearing a different Highlight configuration - solid colors, a dim outline, stacked definitions, and live pulse and hue-cycle mutations - in front of a row of primitives built in code, outlined the same way through a source system of their own. Outlines are read off the visibility buffer, so a thing is outlined only where it is actually visible.",
"category": "Rendering",
"status": "live",
"order": 6,
"tags": [
"rendering",
"highlight",
"outline",
"selection",
"gltf",
"ecs",
"primitives"
],
"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 glTF props showing every Highlight outline configuration — solid colors, opacity, stacked definitions, and live pulse/hue-cycle mutations.",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@woosh/meep-engine": "3.41.0"
},
"devDependencies": {
"@rollup/plugin-strip": "^3.0.4",
"vite": "^8.0.13"
}
}
# Highlight outlines
A 3×3 grid of glTF props. 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 `over` each
other in list order into the single premultiplied color the outline is drawn
in, so game code can stack one per *reason* (selected, targeted, on-fire…)
and remove them independently.
- `HighlightOutlineSystem` — the pass, constructed as
`new HighlightOutlineSystem(graphics)`. It observes no entities; two source
systems tell it what is highlighted. `SGMeshHighlightSystem(outline, meshSystem)`
covers **loaded models** (`Highlight + SGMesh`) and asks the mesh system which
rows a model occupies, so the `Highlight` goes on the prop entity itself, not
on the `ShadedGeometry` primitives `MeshSystem` builds underneath it.
`ShadedGeometryHighlightSystem(outline)` covers **primitives built in code**
(`Highlight + ShadedGeometry`).
- 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
props are each a three-line `animate(t)` callback. (The lookup table is
repacked every frame precisely because highlights animate.)
## How an outline is drawn
An **outline is a neighbourhood test on the visibility buffer**.
The frame already writes a per-pixel mesh id, so the pass packs one rgba8 color
per highlighted mesh row and walks a 3-pixel disc around every pixel — no
second geometry pass, no dilate-and-blur ping-pong, no id render targets.
Two consequences a reader will notice:
- **A highlighted object does not show through what stands in front of it.**
The visibility buffer knows only what was actually rasterized, so an object
is outlined where it can be seen and nowhere else. Orbit until one prop
passes behind another and the far outline is eaten.
- **Drawing and deciding are separate systems.** `HighlightOutlineSystem` owns
the pass and observes no entities; a source system per kind of thing tells it
what is highlighted - `SGMeshHighlightSystem` for a loaded model,
`ShadedGeometryHighlightSystem` for a primitive built in code. Both are
registered here, which is why the front row (a torus knot, an octahedron and
a box, all built in code) is outlined next to the grid of loaded props.
The outline also sits strictly *outside* the silhouette: a pixel that is itself
the highlighted mesh is left alone, so the mark never paints over the thing it
marks.
## Why these three props
Three silhouettes that cannot be confused with one another - a chair, a slumped
bag and a tall bookcase - laid out as a Latin square so that no row or column
repeats one. Neighbouring outlines then sit on different shapes, which is what a
demo about outlines needs to show.
## Run
```sh
npm install
npm run dev
```
## Build
`npm run build` emits the static demo into `public/examples/highlight/`, with
`models/fantasy-props/` copied through from this example's `public/` folder.
## Assets
Three fantasy props under `public/models/fantasy-props/` — `chair`,
`bag-big` and `bookcase`, copied from the `entity-stress-test` example,
which uses the same prop set. Each contributes:
- `<name>.gltf`
- `<name>.bin` (sibling buffer the glTF references)
- the shared `diffuse_1024.png` every one of their materials samples
The prop set is not authored here and carries no licence note in this
repository (see `examples-src/entity-stress-test/README.md`, which says to
bring your own). Any glTF set will do — swap the file names at the top of
`src/main.js`, as long as each prop ships its `.bin` and its texture.
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({
// Vite's dependency pre-bundling rewrites the engine's own asset imports and
// loses their URLs, so a dev server ends up serving ".../deps/undefined" for
// the renderer's data files. The engine is source-only ESM and needs no
// pre-bundling - but its CommonJS dependencies do, or the import of them from inside
// the excluded engine finds no default export.
optimizeDeps: {
exclude: ["@woosh/meep-engine"],
include: ["dat.gui", "opentype.js"],
},
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",
},
});