// Top-down scene example, built on Meep.
//
// 5,000 fantasy props on a ground plane. The camera is top-down at a fixed
// pitch/yaw/distance and is panned by:
// - WASD or arrow keys (held; integrated in postRender)
// - left-mouse drag on the main viewport (drag a world point under the
// cursor as you'd expect from any 2D map UI)
// - the minimap (click or drag in the corner widget)
//
// Engine pieces in use:
// - EngineHarness.bootstrap / .buildBasics
// - GLTFAssetLoader + TextureAssetLoader (engine.assetManager)
// - SGMesh / SGMeshSystem (one-line per-instance from URL)
// - ShadedGeometrySystem (instanced batching)
// - AmbientOcclusionPostProcessEffect (config.addPlugin)
// - TopDownCameraController (we drive its `target`; the
// controller's system re-derives
// the camera transform each tick)
import {
plane3_compute_ray_intersection
} from "@woosh/meep-engine/src/core/geom/3d/plane/plane3_compute_ray_intersection.js";
import { SurfacePoint3 } from "@woosh/meep-engine/src/core/geom/3d/SurfacePoint3.js";
import Vector3 from "@woosh/meep-engine/src/core/geom/Vector3.js";
import { GameAssetType } from "@woosh/meep-engine/src/engine/asset/GameAssetType.js";
import { GLTFAssetLoader } from "@woosh/meep-engine/src/engine/asset/loaders/GLTFAssetLoader.js";
import { TextureAssetLoader } from "@woosh/meep-engine/src/engine/asset/loaders/texture/TextureAssetLoader.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { ParentEntity } from "@woosh/meep-engine/src/engine/ecs/parent/ParentEntity.js";
import { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.js";
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import { Camera } from "@woosh/meep-engine/src/engine/graphics/ecs/camera/Camera.js";
import TopDownCameraController
from "@woosh/meep-engine/src/engine/graphics/ecs/camera/topdown/TopDownCameraController.js";
import Highlight from "@woosh/meep-engine/src/engine/graphics/ecs/highlight/Highlight.js";
import {
ShadedGeometryHighlightSystem
} from "@woosh/meep-engine/src/engine/graphics/ecs/highlight/system/ShadedGeometryHighlightSystem.js";
import { SGMesh } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/aggregate/SGMesh.js";
import {
SGMeshHighlightSystem
} from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/aggregate/SGMeshHighlightSystem.js";
import { SGMeshSystem } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/aggregate/SGMeshSystem.js";
import { ShadedGeometry } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometry.js";
import { ShadedGeometryFlags } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometryFlags.js";
import { ShadedGeometrySystem } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometrySystem.js";
import {
AmbientOcclusionPostProcessEffect
} from "@woosh/meep-engine/src/engine/graphics/render/buffer/simple-fx/ao/AmbientOcclusionPostProcessEffect.js";
import * as THREE from "three";
// ─── Scene parameters ───────────────────────────────────────────────────────
const PROP_COUNT = 10_000;
const GROUND_SIZE = 425; // keeps prop density at ~ 5,000 / 300²
const HALF_GROUND = GROUND_SIZE / 2;
const CAMERA_PITCH = 0.9; // rad, ~52° from horizontal
const CAMERA_YAW = Math.PI; // camera sits on +Z and looks toward -Z
const CAMERA_DISTANCE = 45;
const KEYBOARD_PAN_SPEED = 60; // world units / sec at full deflection
const PROP_URL_BASE = "./models/fantasy-props/";
const PROP_URLS = [
"chair.gltf",
"table-1.gltf",
"bed.gltf",
"bookcase.gltf",
"chest-wood.gltf",
"bag-big.gltf",
"bag-01.gltf",
"candles-1.gltf",
"torch-long-1.gltf",
"coins-1.gltf",
"gem-blue.gltf",
"gem-green.gltf",
"gem-red.gltf",
"book-1-low.gltf",
].map((p) => PROP_URL_BASE + p);
// ─── Bootstrap ──────────────────────────────────────────────────────────────
const engine = await EngineHarness.bootstrap({
configuration: (config, engine) => {
const gltfLoader = new GLTFAssetLoader();
config.addLoader(GameAssetType.ModelGLTF, gltfLoader);
config.addLoader(GameAssetType.ModelGLTF_JSON, gltfLoader);
config.addLoader(GameAssetType.Texture, new TextureAssetLoader());
config.addSystem(new SGMeshSystem(engine));
config.addSystem(new ShadedGeometrySystem(engine));
// Highlight rendering. SGMeshHighlightSystem propagates a Highlight on the
// top-level SGMesh entity down to its ShadedGeometry children;
// ShadedGeometryHighlightSystem does the actual outline rendering.
config.addSystem(new SGMeshHighlightSystem());
config.addSystem(new ShadedGeometryHighlightSystem(engine));
config.addPlugin(AmbientOcclusionPostProcessEffect);
},
});
engine.plugins.acquire(AmbientOcclusionPostProcessEffect).then(ao => {
// boost contrast on AO
ao.getValue().intensity = 14;
});
await EngineHarness.buildBasics({
engine,
enableTerrain: false,
enableWater: false,
enableLights: true,
enableShadows: false,
focus: new Vector3(0, 0, 0),
distance: CAMERA_DISTANCE,
pitch: CAMERA_PITCH,
yaw: CAMERA_YAW,
cameraFieldOfView: 45,
cameraFarDistance: 1000,
cameraController: false, // we wire our own input
showFps: false,
});
const ecd = engine.entityManager.dataset;
// ─── Procedural ground texture ──────────────────────────────────────────────
//
// Two-scale grid:
// - Minor lines every GROUND_MINOR_WORLD_UNITS units — fine sense of scale
// next to the props.
// - Major lines every GROUND_MAJOR_WORLD_UNITS units — thicker, brighter,
// act as bigger landmarks.
// Olive base color stays.
const GROUND_MINOR_WORLD_UNITS = 2; // 1 minor cell = 2 world units
const GROUND_MAJOR_WORLD_UNITS = 8; // major line every 4 minor cells
const GROUND_MINOR_PER_TILE = 8; // texture tile spans 8 minor cells
const GROUND_TILE_WORLD_UNITS = GROUND_MINOR_PER_TILE * GROUND_MINOR_WORLD_UNITS; // 16
const MAJOR_STEP_CELLS = GROUND_MAJOR_WORLD_UNITS / GROUND_MINOR_WORLD_UNITS; // 4
function makeGroundTexture() {
const size = 256;
const cellPx = size / GROUND_MINOR_PER_TILE; // 32 px per minor cell
const canvas = document.createElement("canvas");
canvas.width = canvas.height = size;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#3c4838";
ctx.fillRect(0, 0, size, size);
function crosshatch(positions, lineWidth, color) {
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
for (const p of positions) {
ctx.beginPath();
ctx.moveTo(p, 0);
ctx.lineTo(p, size);
ctx.moveTo(0, p);
ctx.lineTo(size, p);
ctx.stroke();
}
}
// Minor lines — every slot that isn't also a major.
const minorPositions = [];
for (let i = 1; i < GROUND_MINOR_PER_TILE; i++) {
if (i % MAJOR_STEP_CELLS === 0) continue;
minorPositions.push(Math.floor(i * cellPx) + 0.5);
}
crosshatch(minorPositions, 1, "rgba(255, 255, 255, 0.09)");
// Major lines — thicker and brighter.
const majorPositions = [];
for (let i = 0; i < GROUND_MINOR_PER_TILE; i += MAJOR_STEP_CELLS) {
// Inset the tile-boundary line slightly so it doesn't bleed across the wrap seam.
majorPositions.push(i === 0 ? 1.5 : Math.floor(i * cellPx) + 0.5);
}
crosshatch(majorPositions, 1.5, "rgba(255, 255, 255, 0.20)");
const texture = new THREE.CanvasTexture(canvas);
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
const tiles = GROUND_SIZE / GROUND_TILE_WORLD_UNITS;
texture.repeat.set(tiles, tiles);
texture.anisotropy = 16; // keep lines crisp at glancing angles
texture.magFilter = THREE.NearestFilter; // sharp grid under magnification
return texture;
}
// ─── Ground plane ───────────────────────────────────────────────────────────
const groundGeometry = new THREE.PlaneGeometry(GROUND_SIZE, GROUND_SIZE);
groundGeometry.rotateX(-Math.PI / 2);
const groundMaterial = new THREE.MeshLambertMaterial({ map: makeGroundTexture() });
const groundSG = ShadedGeometry.from(groundGeometry, groundMaterial);
groundSG.clearFlag(ShadedGeometryFlags.CastShadow);
groundSG.setFlag(ShadedGeometryFlags.ReceiveShadow);
const groundEntity = new Entity()
.add(new Transform())
.add(groundSG)
.build(ecd);
// ─── Scatter props ──────────────────────────────────────────────────────────
for (let i = 0; i < PROP_COUNT; i++) {
const url = PROP_URLS[(Math.random() * PROP_URLS.length) | 0];
const halfAngle = Math.random() * Math.PI;
new Entity()
.add(Transform.fromJSON({
position: {
x: (Math.random() - 0.5) * (GROUND_SIZE - 4),
y: 0,
z: (Math.random() - 0.5) * (GROUND_SIZE - 4),
},
rotation: {
x: 0,
y: Math.sin(halfAngle),
z: 0,
w: Math.cos(halfAngle),
},
}))
.add(SGMesh.fromURL(url))
.build(ecd);
}
// ─── Get the camera entity and its controller ───────────────────────────────
// getAnyComponent returns a { component, entity } record — unwrap.
const cameraEntry = ecd.getAnyComponent(Camera);
const camera = cameraEntry.component;
const cameraEntity = cameraEntry.entity;
const controller = ecd.getComponent(cameraEntity, TopDownCameraController);
function clampToGround(v) {
if (v < -HALF_GROUND) return -HALF_GROUND;
if (v > HALF_GROUND) return HALF_GROUND;
return v;
}
// All panning — keyboard or mouse — goes through TopDownCameraController.pan,
// which reads the camera's matrix and converts a screen-space pixel delta
// into a target offset in world coordinates. We never have to think about
// the camera's rotation ourselves.
//
// `pan` expects: a Vector2-shaped delta (right & down positive), the THREE
// camera object, an element whose clientHeight matches the rendered viewport,
// the camera's distance to target, the FOV in degrees, and the result vector
// (we pass controller.target directly).
const panDelta = { x: 0, y: 0 };
const viewportEl = document.documentElement; // same height as the canvas
function applyPan(pixelDx, pixelDy) {
panDelta.x = pixelDx;
panDelta.y = pixelDy;
TopDownCameraController.pan(
panDelta,
engine.graphics.camera,
viewportEl,
controller.distance,
camera.fov.getValue(),
controller.target,
);
controller.target.set(
clampToGround(controller.target.x),
0,
clampToGround(controller.target.z),
);
}
// ─── Keyboard panning ───────────────────────────────────────────────────────
//
// `engine.devices.keyboard.keys.{name}.is_down` is the engine's polled state
// for each key; the engine handles focus loss, repeat suppression, and
// everything else. We just check the relevant keys each engine tick.
const KEYBOARD_PIXELS_PER_SEC = 600;
function applyKeyboard(dt) {
const k = engine.devices.keyboard.keys;
let sx = 0, sy = 0;
if (k.a.is_down || k.left_arrow.is_down) sx -= 1;
if (k.d.is_down || k.right_arrow.is_down) sx += 1;
if (k.w.is_down || k.up_arrow.is_down) sy -= 1;
if (k.s.is_down || k.down_arrow.is_down) sy += 1;
if (sx === 0 && sy === 0) return;
const len = Math.hypot(sx, sy);
const step = KEYBOARD_PIXELS_PER_SEC * dt / len;
applyPan(sx * step, sy * step);
}
// ─── Mouse drag on the main viewport ────────────────────────────────────────
//
// The engine's PointerDevice is bound to `viewStack.el`; HUD panels are
// siblings outside that scope, so we don't have to filter them out.
// `on.drag` fires with (position, origin, lastPosition, event); we just take
// the per-step delta and feed it to pan().
engine.devices.pointer.on.drag.add((position, _origin, lastPosition) => {
const dx = position.x - lastPosition.x;
const dy = position.y - lastPosition.y;
// Negate so the world drags with the cursor (map-drag style).
applyPan(-dx, -dy);
});
// ─── Click to select ────────────────────────────────────────────────────────
//
// PointerDevice's `tap` signal fires only on a quick press-release that
// doesn't drift more than ~10px — drags don't trigger it, so this is a clean
// signal for "the user actually clicked here".
//
// We raycast the scene via ShadedGeometrySystem.raycastNearest (engine-native,
// uses the renderer's own BVH), exclude the ground, and walk the hit child
// entity up to its SGMesh parent via ParentEntity. The Highlight component
// is then attached to that parent; SGMeshHighlightSystem propagates it
// down to the child ShadedGeometry entities, and ShadedGeometryHighlightSystem
// draws the outline.
const shadedGeometrySystem = engine.entityManager.getSystem(ShadedGeometrySystem);
// Register the Highlight component type once so add/remove via the dataset works.
ecd.registerComponentType(Highlight);
const pickContact = new SurfacePoint3();
const pickOrigin = new Vector3();
const pickDirection = new Vector3();
let selectedEntity = -1;
function findSGMeshAncestor(entity) {
// Note: ecd.getComponent returns `undefined` (not null) when the component
// isn't attached, so the check below has to treat both as "missing".
let current = entity;
// Walk up via ParentEntity until we find the SGMesh entity (or run out).
for (let safety = 0; safety < 16; safety++) {
if (ecd.getComponent(current, SGMesh) !== undefined) return current;
const parentRef = ecd.getComponent(current, ParentEntity);
if (parentRef === undefined || parentRef === null) return -1;
current = parentRef.entity;
}
return -1;
}
function setSelection(entity) {
if (entity === selectedEntity) return;
if (selectedEntity !== -1 && ecd.entityExists(selectedEntity)) {
ecd.removeComponentFromEntity(selectedEntity, Highlight);
}
selectedEntity = entity;
const plaque = document.getElementById("selection-plaque");
if (selectedEntity === -1) {
plaque.hidden = true;
} else {
ecd.addComponentToEntity(selectedEntity, Highlight.fromOne(0.31, 0.94, 0.66, 1));
plaque.hidden = false;
document.getElementById("selection-id").textContent = "#" + selectedEntity;
}
}
const filter_excludes_ground = (entity /*, sg */) => entity !== groundEntity;
engine.devices.pointer.on.tap.add((position) => {
// Pointer position is in client-pixel space; convert to NDC.
const ndcX = (position.x / window.innerWidth) * 2 - 1;
const ndcY = -(position.y / window.innerHeight) * 2 + 1;
camera.projectRay(ndcX, ndcY, pickOrigin, pickDirection);
const hit = shadedGeometrySystem.raycastNearest(
pickContact,
pickOrigin.x, pickOrigin.y, pickOrigin.z,
pickDirection.x, pickDirection.y, pickDirection.z,
filter_excludes_ground,
);
if (hit === undefined || hit === null) {
// Empty space or only the ground in the way → clear selection.
setSelection(-1);
return;
}
// hit.entity is the ShadedGeometry child created by SGMesh. Walk up.
setSelection(findSGMeshAncestor(hit.entity));
});
// ─── Minimap (world-axis-aligned satellite view) ────────────────────────────
const map = document.getElementById("minimap");
const marker = document.getElementById("minimap-marker");
const frustumPoly = document.getElementById("minimap-frustum");
function mapRect() {
return map.getBoundingClientRect();
}
function mapPxToWorld(px, py) {
const rect = mapRect();
return {
x: (px / rect.width - 0.5) * GROUND_SIZE,
z: (py / rect.height - 0.5) * GROUND_SIZE,
};
}
function worldToMapPx(worldX, worldZ) {
const rect = mapRect();
return {
x: (worldX / GROUND_SIZE + 0.5) * rect.width,
y: (worldZ / GROUND_SIZE + 0.5) * rect.height,
};
}
let mapDragging = false;
function minimapApplyAt(clientX, clientY) {
const rect = mapRect();
const px = Math.max(0, Math.min(rect.width, clientX - rect.left));
const py = Math.max(0, Math.min(rect.height, clientY - rect.top));
const w = mapPxToWorld(px, py);
controller.target.set(clampToGround(w.x), 0, clampToGround(w.z));
}
map.addEventListener("pointerdown", (e) => {
e.stopPropagation();
mapDragging = true;
map.setPointerCapture(e.pointerId);
minimapApplyAt(e.clientX, e.clientY);
});
map.addEventListener("pointermove", (e) => {
if (!mapDragging) return;
minimapApplyAt(e.clientX, e.clientY);
});
map.addEventListener("pointerup", (e) => {
mapDragging = false;
map.releasePointerCapture(e.pointerId);
});
// Refresh the minimap each engine frame — handles all input sources uniformly.
// Frustum projection uses meep's own facilities: the Camera component's
// projectRay turns an NDC coord into a world-space ray, and the engine's
// plane3_compute_ray_intersection does the ground-plane intersection.
// No THREE.Raycaster needed.
const NDC_CORNERS = [[-1, -1], [1, -1], [1, 1], [-1, 1]];
const rayOrigin = new Vector3();
const rayDirection = new Vector3();
const rayHit = new Vector3();
function refreshMinimap() {
const p = worldToMapPx(controller.target.x, controller.target.z);
marker.style.left = `${p.x}px`;
marker.style.top = `${p.y}px`;
if (!engine.graphics.camera) return;
const points = [];
for (const [nx, ny] of NDC_CORNERS) {
camera.projectRay(nx, ny, rayOrigin, rayDirection);
plane3_compute_ray_intersection(
rayHit,
rayOrigin.x, rayOrigin.y, rayOrigin.z,
rayDirection.x, rayDirection.y, rayDirection.z,
0, 1, 0, 0, // ground plane: normal +Y, offset 0
);
const mp = worldToMapPx(rayHit.x, rayHit.z);
points.push(`${mp.x.toFixed(1)},${mp.y.toFixed(1)}`);
}
frustumPoly.setAttribute("points", points.join(" "));
}
// ─── HUD + frame loop ───────────────────────────────────────────────────────
const fpsEl = document.getElementById("fps");
const countEl = document.getElementById("count");
let fpsWindow = 0;
let fpsFrames = 0;
let lastFrame = performance.now();
engine.graphics.on.postRender.add(() => {
const now = performance.now();
const dt = (now - lastFrame) / 1000;
lastFrame = now;
applyKeyboard(dt);
refreshMinimap();
fpsWindow += dt;
fpsFrames++;
if (fpsWindow >= 0.5) {
fpsEl.textContent = (fpsFrames / fpsWindow).toFixed(0);
countEl.textContent = ecd.entityCount.toLocaleString();
fpsWindow = 0;
fpsFrames = 0;
}
});
refreshMinimap();
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Top-down scene · 5,000 props · 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;
}
.hud .label {
color: #6b7785;
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.65rem;
margin-right: 0.5rem;
}
.hud .value { color: #4ef0a8; }
/* Selection plaque — appears below the FPS HUD when an entity is selected. */
.selection {
top: 5.75rem;
left: 1rem;
padding: 0.55rem 0.85rem;
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 0.82rem;
line-height: 1.4;
color: #9aa5b1;
border-color: #2a3a32;
}
.selection .label {
color: #6b7785;
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.65rem;
margin-right: 0.6rem;
}
.selection .value {
color: #4ef0a8;
font-weight: 600;
}
.back {
top: 1rem; right: 1rem;
font-family: ui-monospace, monospace;
font-size: 0.78rem;
color: #4ef0a8;
text-decoration: none;
padding: 0.55em 0.95em;
}
.back:hover { color: #2dd185; }
/* Minimap — bottom-right. The big square is the whole ground; the small
inner square is the camera target. Click anywhere or drag the marker. */
.minimap-wrap {
bottom: 1rem; right: 1rem;
padding: 0.7rem;
}
.minimap-header {
font-family: ui-monospace, monospace;
font-size: 0.65rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #6b7785;
margin-bottom: 0.5rem;
text-align: center;
}
#minimap {
position: relative;
width: 200px;
height: 200px;
background:
linear-gradient(to right, rgba(78,240,168,0.06) 1px, transparent 1px) 0 0 / 25px 25px,
linear-gradient(to bottom, rgba(78,240,168,0.06) 1px, transparent 1px) 0 0 / 25px 25px,
#11161d;
border: 1px solid #2a3441;
border-radius: 4px;
cursor: crosshair;
touch-action: none;
user-select: none;
-webkit-user-select: none;
overflow: hidden;
}
#minimap svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
#minimap-frustum {
fill: rgba(78, 240, 168, 0.18);
stroke: rgba(78, 240, 168, 0.85);
stroke-width: 1.5;
stroke-linejoin: round;
}
#minimap-marker {
position: absolute;
width: 8px;
height: 8px;
background: #fff;
border: 1.5px solid #4ef0a8;
border-radius: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
box-shadow: 0 0 6px rgba(78,240,168,0.7);
}
.legend {
bottom: 1rem; left: 1rem;
max-width: 360px;
padding: 0.8rem 1rem;
font-size: 0.82rem;
line-height: 1.55;
color: #9aa5b1;
}
.legend strong { color: #e6edf3; }
.legend code {
font-family: ui-monospace, monospace;
color: #4ef0a8;
font-size: 0.95em;
}
.legend kbd {
font-family: ui-monospace, monospace;
font-size: 0.78em;
color: #e6edf3;
background: #11161d;
border: 1px solid #2a3441;
border-bottom-width: 2px;
border-radius: 3px;
padding: 0.05em 0.4em;
}
</style>
</head>
<body>
<div class="panel hud">
<div><span class="label">fps</span><span class="value" id="fps">--</span></div>
<div><span class="label">entities</span><span class="value" id="count">--</span></div>
</div>
<div class="panel selection" id="selection-plaque" hidden>
<span class="label">selected</span><span class="value" id="selection-id">--</span>
</div>
<div class="panel minimap-wrap">
<div class="minimap-header">Minimap · view frustum + target</div>
<div id="minimap">
<svg viewBox="0 0 200 200" preserveAspectRatio="none">
<polygon id="minimap-frustum" points=""></polygon>
</svg>
<div id="minimap-marker"></div>
</div>
</div>
<div class="panel legend">
<kbd>WASD</kbd> / arrows / drag to pan · click to select
</div>
<script type="module" src="./src/main.js"></script>
</body>
</html>
{
"name": "@meep-examples/entity-stress-test",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "250,000-entity dynamic scene at 60fps — Meep ECS + Three.js InstancedMesh.",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@woosh/meep-engine": "^2.138.1",
"three": "0.136.0"
},
"devDependencies": {
"vite": "^8.0.13"
}
}
# entity-stress-test
A top-down scene with 10,000 fantasy props scattered on a ground plane, click-to-select, and a minimap with a view-frustum overlay.
## What this example demonstrates
- **Engine bootstrap** with full graphics — `EngineHarness.bootstrap()` + `EngineHarness.buildBasics({ enableLights: true })`.
- **Asset pipeline** — `GLTFAssetLoader` and `TextureAssetLoader` registered against `engine.assetManager`. Each prop is `SGMesh.fromURL(path)`; the engine fetches the `.gltf`, its `.bin`, and the diffuse texture, caches them, and produces child entities with `ShadedGeometry` per primitive.
- **Instanced rendering** — `ShadedGeometrySystem` batches every entity that shares a geometry + material into a single instanced draw call. 10,000 props across ~15 unique mesh URLs collapse to a handful of draw calls plus the ground.
- **Post-processing** — SSAO via `config.addPlugin(AmbientOcclusionPostProcessEffect)`. The plugin slots itself into the engine's compositing stages; no per-frame code.
- **Camera-without-controller** — `buildBasics({ cameraController: false })` produces a `TopDownCameraController` without input wiring. WASD/arrows are polled through `engine.devices.keyboard`, mouse-drag is driven by `engine.devices.pointer.on.drag`, and both feed into `TopDownCameraController.pan(...)`.
- **Click-to-select** — `engine.devices.pointer.on.tap` → `camera.projectRay()` → `shadedGeometrySystem.raycastNearest()` (filtered to skip the ground), then walk `ParentEntity` to the prop's `SGMesh` ancestor and toggle a `Highlight` component on it.
## Run locally
```bash
npm install
npm run dev
```
## Build
```bash
npm run build
```
Output goes to `../../public/examples/entity-stress-test/demo.html` plus the hashed bundle and the `models/fantasy-props/` subtree copied verbatim from this example's `public/` folder.
## Assets
The example expects fantasy-prop GLTFs under `public/models/fantasy-props/`. Each prop contributes:
- `<name>.gltf`
- `<name>.bin` (sibling buffer the GLTF references)
- A shared `diffuse_1024.png` (referenced by every material)
The prop names loaded by `src/main.js` live at the top of that file. Drop matching GLTFs into `public/models/fantasy-props/` and they'll be picked up on the next dev/build. Any GLTF set will do as long as each prop ships its `.bin` and shares the diffuse texture.
These assets are not redistributed with the example source — bring your own.
import { defineConfig } from "vite";
import { fileURLToPath } from "node:url";
import { resolve, dirname } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
// Each example builds into the corresponding folder under the main site's
// public/examples/{id}/. The site serves the result at /examples/{id}/demo.html.
//
// `emptyOutDir: false` is critical — meta.json, thumbnail.svg, and any other
// hand-maintained files live in the same folder and must survive a rebuild.
//
// `base: "./"` keeps the built HTML's asset references relative, so the
// bundle works at any URL the host serves it from.
export default defineConfig({
base: "./",
build: {
outDir: resolve(__dirname, "../../public/examples/entity-stress-test"),
emptyOutDir: false,
rollupOptions: {
input: resolve(__dirname, "demo.html"),
},
target: "es2022",
},
});