UI

Minimap & previews

The Canvas2D minimap - focus area, terrain, markers and fog of war drawn with 2D canvas calls - plus make_model_thumbnail for offscreen model pictures and the CanvasView primitive.

Two things in the UI layer show you the world rather than a number: the minimap, and a picture of a model for an inventory slot or an asset library. Neither of them is a renderer. The minimap draws with 2D canvas calls over data the CPU already owns, and a model thumbnail is a function that hands back pixels from the renderer that is already running - there is no second device, no second context and no widget holding one open.

MinimapView

MinimapMarkerCollection observes entities carrying Transform64 + MinimapMarker. Movement is tracked through the dataset’s TRANSFORM64_EVENT_CHANGE event, so announce manual pose writes with t64_announce_change(ecd, entity). Marker icon changes also trigger a redraw.

MinimapView (in src/view/minimap/Minimap.js, a default export) is the top-level container. It owns one MinimapCanvasView for the map itself, an SVG camera frame per camera entity on top of it, and the pointer handling that turns a click into a camera move.

import MinimapView from "@woosh/meep-engine/src/view/minimap/Minimap.js";

const minimap = new MinimapView(entityManager.dataset, engine.assetManager, engine.graphics);
minimap.size.set(200, 200);
containerView.addChild(minimap);

The third argument is the GraphicsEngine, and it is what the camera frames trace their rays with: graphics.viewportProjectionRay.

You do not pass terrain or cameras. They are discovered from the dataset: cameras through an EntityObserver over [Camera, Transform64] that runs on link(), terrain by traversal.

minimap.world is a Rectangle in world XZ - what part of the world is on screen. The map decides it from the explored area every time it draws, and the camera frames and the click-to-focus arithmetic read the same object.

MinimapCanvasView

MinimapCanvasView (in src/view/minimap/MinimapCanvasView.js, named, extends CanvasView) is the map. Everything it shows is CPU-authoritative already - the terrain preview is a generated image, the fog is the same Sampler2D gameplay reads every frame, and the markers are entity positions - so drawing it needs nothing from the GPU.

import { MinimapCanvasView } from "@woosh/meep-engine/src/view/minimap/MinimapCanvasView.js";
import Rectangle from "@woosh/meep-engine/src/core/geom/2d/Rectangle.js";

const focus_area = new Rectangle(0, 0, 0, 0);

const map = new MinimapCanvasView({ dataset, assets: assetManager, focus_area });
map.size.set(200, 200);

The focus rectangle is owned by the caller and rewritten by the view on every draw, which is how MinimapView shares one rectangle between the map, the camera frames and the pointer maths.

Drawing is coalesced into a single requestAnimationFrame and only happens when something moved - a strategy map is still most of the time. These are the things that schedule a redraw:

TriggerSource
A marker moved, appeared, or got an iconMinimapMarkerCollection.on.changed
A picture that was asked for arrivedMinimapImageCache.on.loaded
The fog changedFogOfWar.on.textureChanged
The terrain preview’s offset or scale changedTerrain.preview
The view was resizedsize.onChanged

The fog signal is load-bearing: FogOfWar writes into its mask array in place - a fill for a conceal, a cell at a time for a fade - so neither the array’s identity nor the sampler’s version moves when the fog does. The component announcing its own change is the only notice there is.

The draw order

focus area, from the fogtransform to world XZterrain image, smoothing offmarker icons, smoothing onfog mask, scaled up

1. Focus area. minimap_focus_area(result, fow, viewport_width, viewport_height) computes the world rectangle to draw. The fog is what frames the minimap: the rectangle is the revealed cells’ bounding box, padded by two fog cells so the frame does not cut through the soft explored edge, then grown on its shorter axis until it has the viewport’s proportions - a rectangle of world drawn into a differently shaped rectangle of screen is a stretched map, with the two axes at different scales and distances reading wrong. Nothing revealed is a real state (the first frames of a run) and frames the whole field instead.

A dataset with no FogOfWar component draws nothing at all, and a zero-sized focus area returns early.

2. World transform. One setTransform maps the focus rectangle onto the canvas, and from there everything is in world XZ, with world +Z running down the screen. Marker positions come from transform.translation_x and transform.translation_z with no conversion step.

3. Terrain. drawImage of the picture at terrain.preview.url, obtained from MinimapImageCache, placed at -preview.offset and sized by preview.scale - one preview pixel is one world cell at the preview’s own scale. imageSmoothingEnabled is off: the preview is a picture of the grid, and its pixels are meant to be seen as pixels. A terrain whose preview has never been generated (url === "") contributes nothing.

4. Markers. Smoothing back on - marker icons are drawn well below their authored resolution, so they want filtering. Each entry is drawn centred on its entity’s world position at the marker’s own size, in the collection’s order, so the last entry lands on top.

5. Fog. The fog mask as an image, one texel per world cell, drawn scaled up with smoothing on. Then the world beyond the mask - the frame can reach past where the level ends - is filled with the fog’s own colour, so unexplored reads as unexplored right out to the edge of the frame.

Because the fog is painted after the markers, a marker standing in unexplored territory is concealed by it.

Why the fog agrees with the world

minimap_fog_image(output, fow) builds that image, and the smoothing it applies is fog_of_war_blur_mask from src/engine/ecs/fow/fog_of_war_blur.js - the same function the renderer’s pass is generated from. src/shade/renderer/fow/shader_fog_of_war.js interpolates FOG_OF_WAR_BLUR_SAMPLE_COUNT (5), FOG_OF_WAR_BLUR_SIGMA (1.2) and FOG_OF_WAR_BLUR_TAP_SPACING (1) - the module’s own exported constants - straight into its WGSL. Same kernel, same taps, same clamp at the edges, so the two agree about where the explored edge falls. The fog grid is one texel per world cell and an unsmoothed read is a grid of squares: the blur is the look, not an approximation of one, which is why every consumer has to apply the same one.

That is also why smoothing is left on for this one drawImage. The renderer evaluates the fog per screen pixel, reading a smoothed mask through a bilinear sampler whose taps land a whole texel apart; interpolating a smoothed mask is the same operation as smoothing an interpolated one. So the smoothing happens once per mask texel here and the interpolation is left to the canvas. Turning it off would show the fog as a grid of squares.

The two composite in different spaces, on purpose. The world pass runs at FramePhase.AfterTransparency, which puts the fog inside the tonemapper rather than over it - what it writes is scene light, in linear space. A canvas has no tonemapper and no exposure, so the minimap writes the authored colour as it stands, in display space. That is what keeps the minimap’s fog looking the same from one frame to the next while the world’s exposure moves.

Markers

MinimapMarkerCollection (named, src/view/minimap/MinimapMarkerCollection.js) is the half of the marker problem that has nothing to do with painting. It follows every entity carrying MinimapMarker + Transform64, keeps collection.markers sorted ascending by zIndex, and raises on.changed when a marker’s icon changes or the dataset announces a transform change. The view reads the list and draws it. Entity event listeners and icon bindings are removed when a marker leaves the collection.

The list is short - a few dozen markers on a whole map - and always sorted, so a new entry is spliced into place rather than the list being re-sorted after every placement.

Each entry exposes the marker, its transform and its entity. Of the component itself the view reads three fields: iconURL (an observed string; a marker whose icon is still empty is skipped, which is what a placement blueprint looks like before the theme fills it in), size (a Vector2, in world units), and zIndex.

Pictures

MinimapImageCache (named) holds the pictures the map draws - the terrain preview and the marker icons - as canvases the 2D context can blit. They come through the AssetManager as GameAssetType.Image rather than through an <img> element, because that is where the game’s images live: the path resolves the same way, the bytes are fetched once however many things want them, and decoding happens off the main thread.

get(url) returns undefined the first time and starts the fetch; on.loaded says when to ask again. The set is held for as long as the view that owns it, so taking the map down and putting it back up re-decodes nothing.

Camera frames

MinimapCameraView (named, src/view/minimap/dom/MinimapCameraView.js) is a DOM layer over the canvas: an SVG <polygon> per entity carrying Camera + Transform64. It projects the four screen corners with graphics.viewportProjectionRay, casts each ray at the terrain with Terrain.raycastFirstSync (retrying with a jittered origin up to ten times, because a ray landing exactly on a polygon edge can miss), falls back to the Y = 0 plane when there is no hit or no terrain, and converts the four contact points into minimap space.

Because the ray comes from the graphics engine, the frame traces the view the player actually has rather than this entity’s camera. The two are the same wherever a game has one camera, which is everywhere it has a minimap.

The map is also clickable. A PointerDevice on the world container turns down and drag into focus(layerX, layerY), which maps canvas coordinates back to world XZ through the focus rectangle and writes TopDownCameraController.target on the active camera’s entity. An entity without that controller is left alone.

No layer API

There is no addLayer, no MinimapWorldLayer registration, and no custom-layer extension point. A minimap that has to draw something the built-in passes do not is a CanvasView of your own, drawn over MinimapView inside the same container.

Five files in the directory have no importer: gl/MarkerGL.js, gl/MarkerGLAttributes.js, gl/MinimapWorldLayer.js, dom/MinimapMarkerView.js and dom/MinimapTerrainView.js. Treat all five as absent.

Model thumbnails

There is no drop-in model-preview widget under src/view/ - no drag-to-rotate, no clip playback. What there is instead is a function from a loaded model to pixels.

make_model_thumbnail(graphics, bundle, width, height, margin = 0) -> Promise<Sampler2D|null>
import { load_model_scene_bundle }
    from "@woosh/meep-engine/src/engine/asset/load_model_scene_bundle.js";
import { make_model_thumbnail }
    from "@woosh/meep-engine/src/engine/graphics3/preview/make_model_thumbnail.js";
import { sampler2d_to_html_canvas }
    from "@woosh/meep-engine/src/engine/graphics/texture/sampler/sampler2d_to_html_canvas.js";

const bundle = await load_model_scene_bundle(engine.assetManager, "models/sword.glb");
const sampler = await make_model_thumbnail(engine.graphics, bundle, 128, 128);

if (sampler !== null) {
    // the sampler is already 8-bit RGBA, so scale 1 and offset 0
    slot.appendChild(sampler2d_to_html_canvas(sampler, 1, 0));
}

make_model_thumbnail is only the order of four steps, each of which ships on its own under @woosh/meep-engine/src/engine/graphics3/preview/ (all named exports):

ExportModuleWhat it answers
model_preview_framing(result, bounds, fov, margin)model_preview_framing.jshow to frame the model
ModelPreviewFramingModelPreviewFraming.jsthe answer: scale, offset (Vector3), camera_distance
make_model_preview_scene(bundle, framing, aspect)make_model_preview_scene.js{scene, camera, drawn} - a scene of one model
MODEL_PREVIEW_FOVmake_model_preview_scene.js45° in radians, the FOV a preview is drawn with
make_model_thumbnail(...)make_model_thumbnail.jsthe four in order, plus the readback

Where the model is comes from shade_bundle_bounds (src/engine/graphics3/shade_bundle_bounds.js), which refreshes matrices first - a bundle handed straight off a loader has never had updateMatrices run on it.

null is a normal result

A model with nothing to draw has no picture, and that is data rather than a fault. A glTF of empties has no bounds; a glTF whose every mesh is skinned has no drawable node, because a preview scene registers no skin and a skinned mesh therefore is not in it. Either way the answer is null, and a caller showing icons should show none for that asset rather than a blank square that reads as a broken renderer.

Framing

The scale is uniform and taken from the tightest of the three axes, so a model of any proportion lands inside the same unit box and every icon in a library is drawn at the same apparent size. A model’s own units mean nothing here. The box is then centred on the origin, and the camera distance is measured from the front face of the centred box.

margin is how much of the frame is left empty around the model, as a fraction of the frame’s height on each side: 0 fills it edge to edge, 0.25 leaves a quarter of the frame’s height clear above and below.

The camera stands at -camera_distance on Z looking at the origin - Shade’s convention, applied by the scene builder rather than by the framing - with near = 0.01 and far = camera_distance * 4 + 4.

Lighting

A fixed three-light rig, all directional, none casting shadows: a key from over the viewer’s shoulder at intensity 3, a fill straight down the view axis at 1.5 so the side facing you is not read off the key alone, and a dim rim from behind and below at 0.6 so a silhouette does not close up into black.

There is no ambient term. Shade takes ambient from the environment map and a preview scene has none: an icon of a model is a picture of the model, not of a place it would stand in.

It is a GPU readback, and it is tooling-only

The pixels come from GraphicsEngine.render_to_sampler(scene, camera, width, height), whose own documentation says tooling only - a readback is forbidden on any gameplay-affecting path. It draws with the renderer that is already running, so geometry and textures the frame needs are already resident, but it takes the renderer over for the call: pixel_ratio goes to 1, the renderer is resized to the picture, automatic exposure is turned off (a picture of one object against nothing has no scene to read an exposure from, and an adapting one would take the frames it adapts over), eight frames are drawn into an owned texture so the temporal effects have a history to converge from, and everything is restored afterwards. The resize drops the main view’s temporal history either way.

It throws when there is no device - before a successful graphics.start(), or after stop().

Cache the promise

A thumbnail is a whole model load plus a render plus a readback. Cache the promise, keyed by url and size, so a second asker awaits the first render rather than starting another one; that is what the editor’s own asset library does.

CanvasView

CanvasView (named, in src/view/elements/CanvasView.js) is the primitive the minimap is built on: a View wrapping an HTMLCanvasElement, exposing its 2D context as this.context2d and keeping the canvas dimensions in sync with View.size.

import { CanvasView } from "@woosh/meep-engine/src/view/elements/CanvasView.js";

const canvas = new CanvasView();
canvas.size.set(256, 64);

canvas.on.linked.add(() => {
    const ctx = canvas.context2d;
    ctx.fillStyle = "#3498db";
    ctx.fillRect(0, 0, 256, 64);
});

clear() wipes the whole canvas. SegmentedResourceBarView uses one for its notch-mark overlay. It is the right choice for any HUD element that needs custom 2D drawing.

  • UI toolkit - View, GUIElement, GUIEngine, controls.
  • Widgets - radial menu, tooltips, currency, resource bars, drag-and-drop, modals, toasts.
  • Render extensions - frame phases, and where the world’s own fog-of-war pass runs.
  • Terrain - the Terrain component and its preview image.
  • Assets - AssetManager, GameAssetType, and loading a model as a SceneBundle.