Viewer Tooling Guide
How the scaffold’s offline asset viewer (tools/viewer/ — generated from
packages/create-seer-app/templates/viewer/*.eta, either directly via npx create-seer-app viewer or as part of npm create seer-app --viewer) works: the
data-driven game/platform selectors, asset-type filter tabs, animation
autoplay, the generic indexed-texture + palette WebGL2 shader with its live
palette editor and colour-cycling control, category-sharded manifest
navigation for large corpora (§5), and 3D asset rendering via
@seer-project/engine-3d (§6).
This is a framework doc describing the scaffold template itself, not a
per-project copy — see “Why this doc isn’t vendored” at the bottom for why it
isn’t scaffolded into new projects the way architecture-overview.md and
boilerplate-guide.md are.
Companion docs: architecture-overview.md §9
(the high-level “what the viewer is for” description this doc backs up with
real architecture), weaknesses.md §6 (the ManifestEntry/
AtlasMeta type-duplication issue this doc’s manifest contract addresses),
viewer-tooling-review.md (the cross-repo survey
that this template revision was built to close the gap identified in),
common-tooling-candidates.md §15a (the
vendoring-vs-linking call this doc’s own placement follows), and
audio-playback.md (the same “divergent hand-rolled
panels unified into shared chrome” story, told for the bottom-docked
music/audio transport bar — @seer-project/core’s PlaybackEngine contract
and @seer-project/audio-ui’s AudioBarController/NativeAudioEngine).
Architecture
Section titled “Architecture”Five template files in packages/create-seer-app/templates/viewer/, rendered
once per scaffolded project into tools/viewer/:
index.html.eta→index.html— DOM structure.viewer.ts.eta→viewer.ts— all browser-side logic, no framework, no build step beyond Vite’s default TS transform.viewer.css.eta→viewer.css— styling.shared.ts.eta→shared.ts— the manifest/atlas/palette types shared between the build pipeline (which writes JSON matching these shapes) and the viewer (which reads it back).data-view.ts.eta→data-view.ts— the “Data” tab’s read-only decoded-JSON table browser, a separate sidebar/list/view from the asset browser (switchTab(),viewer.ts.eta:216-231).
Two Node-side template files feed it:
tools/shared/game-config.ts.eta→tools/shared/game-config.ts— ownsGAME_CONFIGSand now alsowriteGamesManifest(), which derivespublic/assets/games.jsonfrom that same config.tools/game/build-assets.ts.eta→tools/<game>/build-assets.ts— the Stage 2 placeholder; writes a self-contained example asset (baked PNG, indexed PNG, atlas, palette, manifest) so a fresh scaffold’s viewer has something real to show before any decoders are wired in, and callswriteGamesManifest().
Data flow, end to end:
GAME_CONFIGS (game-config.ts) │ ├─ writeGamesManifest() ──────────► public/assets/games.json │ (id, displayName, platforms[]) │ └─ per-platform build-assets.ts ──► public/assets/<game>/<platform>/ manifest.json (ManifestEntry[]) <name>.json (AtlasMeta — packed frames) <name>.png (baked RGBA sheet) <name>.indexed.png (optional — R=index) <name>.pal.json (optional — PaletteData)The viewer fetches games.json once at startup, then manifest.json +
per-asset .json/.png/.pal.json whenever the selected game/platform or
asset changes.
1. Data-driven game + platform selectors
Section titled “1. Data-driven game + platform selectors”Problem this replaces: before this revision, ASSET_BASE was a const
baked in at scaffold-generation time (/assets/<game>/<platform>), so a
project that grew to support more platforms had no in-template way to switch
— every real consuming project hand-rolled its own fix (see
viewer-tooling-review.md divergent §2–3 for how inconsistently that turned
out across six sibling projects).
How it works now:
tools/shared/game-config.ts.eta’swriteGamesManifest()(game-config.ts.eta:73-82) mapsGAME_CONFIGSto{ id, displayName, platforms: [{ id, displayName }] }[]and writes it via@seer-project/pipeline’swriteJson.PlatformConfiggained an optionaldisplayName?: stringfield (game-config.ts.eta:24-28) for this; when omitted, the platform id itself is used as the label (p.displayName ?? p.platform).tools/game/build-assets.ts.etacallswriteGamesManifest(resolve('public/assets/games.json'))as its last step, so the manifest always reflects the current config.viewer.ts.eta’sinitSelectors()(viewer.ts.eta:109-125) fetches/assets/games.jsonvialoadGamesManifest()(:77-84) and populates the#game-select/#platform-select<select>elements (populateSelect(),:87-96), defaulting to the scaffolded game/platform. If the fetch fails or returns an empty array — the only case a truly fresh scaffold can hit beforebuild-assetshas ever run — it falls back to a synthetic single-entry list built from that same default, so the selectors always render something valid.- Changing either
<select>(gameSelectEl/platformSelectElchangelisteners,viewer.ts.eta:144-154) recomputesASSET_BASEand callsswitchAssetBase()(:128-137), which resets selection state, stops any running autoplay/color-cycling, reloadsmanifest.json, and re-renders the type filters and list.
This is correct by construction even for the trivial single-game/single-
platform case a fresh scaffold starts with — GAME_CONFIGS has exactly one
entry, writeGamesManifest() still emits a valid one-element games.json,
and the selectors render one <option> each. Adding a second platform later
is purely a GAME_CONFIGS edit; no viewer template change is needed.
2. Asset-type grouping / filter tabs
Section titled “2. Asset-type grouping / filter tabs”ManifestEntry (now the single canonical definition, in shared.ts.eta —
see “Type reconciliation” below) gained a type: string field. viewer.ts.eta’s
renderTypeFilters() (:156-181) derives the filter buttons from
[...new Set(manifest.map(m => m.type))] — the distinct types actually
present in the loaded manifest — rather than a hardcoded asset-type union, so
it works unchanged whether a project has one asset type or ten. renderList()
(:184-208) applies both the active type filter and the search query.
The scaffold’s own placeholder build-assets.ts.eta writes exactly one
manifest entry with type: 'sprite', so a fresh, unmodified project shows a
single, sensible filter tab rather than an empty or confusing filter bar.
3. Animation autoplay
Section titled “3. Animation autoplay”viewer.ts.eta:634-660. A Play/Pause button (#play-toggle, in
index.html.eta’s #frame-strip, next to the existing slider) toggles a
setInterval timer that advances currentFrame with wraparound and calls
drawAsset() — the same redraw path manual stepping already used. The
timer interval is a single named constant, AUTOPLAY_INTERVAL_MS = 150
(viewer.ts.eta:634), specifically so it’s easy to find and tune.
Manually dragging the slider (frameSlider’s input listener) or pressing
the arrow keys both call stopPlayback() first — the existing arrow-key
stepping logic itself (wraparound left/right) is otherwise unchanged from
before this revision.
4. Indexed-texture + palette WebGL2 shader, palette editor, colour cycling
Section titled “4. Indexed-texture + palette WebGL2 shader, palette editor, colour cycling”Build side: emitting an indexed PNG variant
Section titled “Build side: emitting an indexed PNG variant”tools/game/build-assets.ts.eta writes, alongside the existing baked RGBA
<name>.png, an optional <name>.indexed.png via @seer-project/pipeline’s
writeIndexedPNG (palette index in the R channel). ManifestEntry.indexedPng
(shared.ts.eta) records its presence; the viewer only offers the shader
toggle for assets that have one (selectAsset(), viewer.ts.eta:210-227).
The scaffold’s own placeholder asset ships a small 2-index checkerboard
specifically so the shader/editor/cycling views have something visibly
non-trivial to render before any real decoder exists.
The shader: a deliberately generic “no-op” base case
Section titled “The shader: a deliberately generic “no-op” base case”viewer.ts.eta:320-524. This is the “basic shader to show how it could be
used” extension point, not a finished per-game recolour system. The fragment
shader (SHADER_FRAGMENT_SRC, :331-345) does exactly this, per pixel:
vec2 atlasUV = uFrameRect.xy + vUV * uFrameRect.zw; // window into the atlasvec4 packed = texture(uIndexTex, atlasUV); // sample the index (R channel)float index = packed.r * 255.0;vec4 color = texture(uPaletteTex, vec2((index + 0.5) / uPaletteSize, 0.5)); // 1D palette lookupfragColor = vec4(color.rgb, packed.a); // pass the index texture's own alpha throughuFrameRect is the only bit of plumbing beyond the bare index→palette
lookup — it lets one shader program serve both a single cropped sprite frame
and the “full atlas” view, exactly like the existing Canvas2D
drawImage(img, frame.x, frame.y, ...) crop already does. There is no
bitplane permutation, no per-entity mode table, nothing game-specific.
This is intentionally where a project’s own hardware-recolor mechanism
gets layered on, not reimplemented here. For a fully worked example of what
that looks like at the far end of the complexity spectrum — a 48-entry mode
table driving a 5-bitplane permutation for one specific Amiga game’s sprite
recolor trick — see
../../middilgard/docs/tooling/asset-viewer.md
(“SAS-based palette renderer” / “WebGL Shader Pipeline” sections). That
logic is specific to WIME’s hardware and is not generalized into this
scaffold; the generic 80% underneath it — sample an index, look it up in a
palette texture, output the color — is what lives here instead. A project
that needs something like it would fork this shader, add its own uniforms
(e.g. a mode-table lookup or extra bitplane textures), and keep the rest of
the pipeline (uFrameRect windowing, palette texture upload, GL state setup)
unchanged.
GL state (GLState, :347-355) — context, compiled program, both textures,
uniform locations — is rebuilt from scratch on every drawAssetShader() call
(initGL(), :372-417), because the <canvas> element itself is recreated
each time (canvasWrap.innerHTML = ''), matching how the existing Canvas2D
path already worked. uploadIndexTexture() (:419-429) uploads the indexed
PNG once per draw; uploadPaletteTexture() (:431-450) is the one that gets
called repeatedly, on every palette edit and every colour-cycling tick,
without touching the index texture or re-fetching anything.
A checkbox (#shader-toggle, only shown when the selected asset has an
indexedPng) switches drawAsset() (:231-247) between this WebGL path and
the original Canvas2D baked-PNG path. Baked-PNG view is unaffected by palette
edits or cycling — that’s an inherent limitation of pre-baked color, not a
bug; it’s why the shader path exists.
Palette editor
Section titled “Palette editor”renderPalette() (:558-587) now reads from workingPalette — an in-memory
copy of the loaded .pal.json’s colors (selectAsset() populates it via
.map(c => ({...c})), :216) — instead of the raw immutable palette data.
Clicking a swatch calls editPaletteColor() (:536-555), which creates a
native <input type="color"> (simplest possible approach, no extra
dependency), seeds it from the swatch’s current color, and on every input
event writes the new color into workingPalette[index], re-renders the
swatches, and calls redrawShaderIfActive() (:470-474) — which re-uploads
just the palette texture and redraws, a no-op when the shader view isn’t
active.
Colour cycling
Section titled “Colour cycling”#cycle-panel (start index, end index, speed, direction, an
Animate/Stop button) drives cycleTick() (:601-616) on a
requestAnimationFrame loop, gated by a simple frame-counter modulo so
“speed” means “ticks per step” rather than raw animation-frame rate. Each
step calls cyclePalette(workingPalette, start, end, direction) — imported
from @seer-project/core (packages/core/src/palette.ts), not written inline in the
template. cyclePalette() is a small, pure, DOM/WebGL-free utility: it
rotates a sub-range of any array by one step, wrapping only within that
range, and is unit-tested with real (non-mocked) data in
packages/core/src/__tests__/palette.test.ts — forward/reverse rotation,
full-cycle round-trips, out-of-range no-ops, and non-numeric (RGB object)
array elements, since the viewer uses it on {r,g,b} triples rather than raw
indices. It was factored out specifically because it’s reusable beyond this
one viewer — any future browser-side consumer of “rotate this palette range”
needs the exact same array-rotation logic, not a viewer-specific copy of it.
5. Manifest sharding & category navigation
Section titled “5. Manifest sharding & category navigation”Problem this replaces: a project with a genuinely large corpus (tens or
hundreds of thousands of entries) had exactly one option — fetch, parse, and
render the entire manifest.json on every page load — with no way to browse
one slice of the catalog without first downloading and holding all of it in
memory. Several in-code comments already point readers at “docs/viewer.md’s
manifest-sharding section” (viewer.ts.eta:59, viewer.css.eta:66) for a
feature this file, until now, never actually documented.
How it works now:
- Build side:
@seer-project/pipeline’swriteShardedManifest()(packages/pipeline/src/manifest-sharding.ts:80-126) takes an already-built flat entry array and splits it intopublic/assets/<game>/<platform>/manifest/<category>.jsonshards — one per distinctManifestEntry.categoryvalue, entries with nocategoryfalling into an"uncategorized"shard rather than being dropped — plus acategories.jsontop-level index (CategoryIndexEntry[],manifest-sharding.ts:38-56), sorted largest-first. A category whose shard exceedsGROUP_SHARD_THRESHOLD(3000 entries,manifest-sharding.ts:70) is additionally split byManifestEntry.groupintomanifest/<category>/<group>.jsonsub-shards (manifest-sharding.ts:101-118), recorded as that category’sgroups[]breakdown (GroupIndexEntry,:31-36). This is purely additive — it never reads or replacesmanifest.jsonitself (manifest-sharding.ts:14-19), so a project that never calls it keeps the original single-fetch contract unchanged. Categorization itself (deciding whatcategory/groupeach entry gets) is always a per-project concern and stays in that project’s owntools/. - Viewer side:
loadCategoryIndex()(viewer.ts.eta:117-125) fetchescategories.jsonon startup and on every game/platform switch. An empty result — 404, or a project that never sharded — is the deliberate signal for “flat manifest mode”:switchAssetBase()(:188-213) falls back to the original singleloadManifest()fetch unchanged. WhencategoryIndexis non-empty, the flat manifest is never fetched at all;renderCategoryNav()(:292-356) renders a grid of category buttons (each showing a live count) instead of the asset list.selectCategory()(:245-264) fetches a category’s flat shard immediately when it has nogroups(loadCategoryShard(),:127-135, cached per-session inshardCache,:69) — but for a group-sharded category, fetches nothing untilselectGroup()(:267-280) pulls onemanifest/<category>/<group>.jsonsub-shard at a time (loadGroupShard(),:137-145). There is deliberately no “load the whole category at once” escape hatch for a group-sharded category (renderCategoryNav’s own doc comment,:282-291) — that would reintroduce the unvirtualized-DOM cost sharding exists to avoid.
A fresh scaffold’s placeholder build-assets.ts.eta never calls
writeShardedManifest, so categories.json doesn’t exist and the viewer
runs in flat-manifest mode until a project’s own pipeline opts in.
6. 3D assets
Section titled “6. 3D assets”Problem this replaces: two sibling seer-framework projects (flower,
hunter) each grew their own three.js viewport independently — duplicated
scene/camera/render-loop boilerplate, two incompatible in-memory model
shapes (a hand-rolled {verts,edges,faces} polygon shape vs. real glTF
documents), a manual 2D/3D UI toggle button in one of them instead of
asset-driven dispatch, and — until now — zero manifest representation for a
3D asset at all: 3D content had to bypass manifest.json entirely (e.g.
hardcoded per-game path tables) because ManifestEntry had no fields for it
and its sprites/hasPalette/png fields were required, meaningless ones
for a mesh.
How it works now:
-
Asset-driven dispatch, no toggle. A manifest entry’s
typefield (the same field that already drives the 2D filter tabs, §2 above) decides whether an asset renders as 2D or 3D — there is no separate “3D mode” button for the user to find or forget to leave. This scaffold’s own templates don’t implement that dispatch themselves (see the callout at the end of this section); it’s each consuming project’stools/viewer/ viewer.tsthat branches ontypeand calls into@seer-project/engine-3dfor amesh/sceneentry. -
Five new optional
ManifestEntryfields (shared.ts.eta, next to the existingcategory/groupsharding fields):model?: string(path relative toASSET_BASE),modelFormat?: 'gltf' | 'polygon-json'(absent ⇒ inferred frommodel’s extension),modelIndex?: number(index into a multi-model polygon JSON array — e.g. one sharedobjects-geometry.jsonholding hundreds of objects, so many manifest entries can share one fetch instead of one file per object),scene?: string(atype: "scene"entry’s placement data), andskeletal?: boolean(whether the model carriesAnimationMixer-driven skeletal animation vs. a static mesh). Makingsprites/hasPalette/pngoptional at the same time was the one non-additive part of this change — they’re meaningless for a mesh/scene entry, and every existing unguarded read of them in the scaffold template (viewer.ts.eta’s sidebar-item renderer) was audited and fixed to tolerate their absence rather than interpolatingundefinedinto the DOM. -
glTF-native + a polygon adapter, not one hand-rolled shape.
@seer-project/engine-3dsettles the questiondocs/engine-3d-proposal.mdoriginally left open by not picking a single JSON shape.gltf.ts’sloadGltfModel()/toModel3D()(packages/engine-3d/src/gltf.ts:60,:24) load a real glTF 2.0 document viaTHREE.GLTFLoaderand use it as-is — real PBR materials, real textures, real bakedAnimationClips.polygon.ts’snormalizePolygonModel()/normalizePolygonSet()(packages/engine-3d/src/polygon.ts:43,:64) instead normalize a raw{verts,edges,faces}JSON document — aliasingvertices/verts, tolerating a barenumber[]face as well as{verts,fill}— andbuildPolygonModel()(:250) builds one mergedBufferGeometryeach for faces/lines/points. Both paths converge on the same unifying shape,Model3D.object: THREE.Object3D(packages/engine-3d/src/types.ts:71) — the one thing a host ever adds tosession/viewport.root, regardless of which path produced it; onlyrender-modes.tsand, for color,polygon.ts’srecolorPolygonModel()(:309) ever branch onmodel.source(types.ts:38). -
Render/color-mode applicability — reproduced from
packages/engine-3d/README.md’s own applicability tables (not re-derived by hand here, so it can’t silently drift from the package’s actual behavior inrender-modes.ts’ssupportedRenderModes()/defaultRenderMode()/applyRenderMode(),packages/engine-3d/src/render-modes.ts:34,:44,:175):render mode polygon glTF texturedunsupported (no texture data) restores the mesh’s original materials facesmerged, per-vertex-colored geometry flat MeshLambertMaterial, tinted from the original material’s.colorwireframedeclared or derived edge set flat wireframe MeshBasicMaterial, same tintpointsbuilt eagerly lazily built, cached merged THREE.Pointscolor mode polygon glTF paletteneeds an injected ColorResolver(flat grey otherwise)not offered face/object/heightsupported not offered Color-mode application is polygon-only by design: a glTF mesh’s materials already carry real, meaningful color/texture information, so overwriting them with an id- or height-derived tint would be a downgrade, not a feature — a host that wants a uniform glTF tint can still do so directly against
model.object’s materials, nothing is hidden behind a private API. -
createMeshSession()(packages/engine-3d/src/session.ts:64) is the orchestrator a host actually calls — one viewport, the active model, its animation controller — withsession.setModel()/.addModel()/.setRenderMode()/.setColorMode()/.fit()/.stats()/.dispose()(session.ts:43-61) as its surface.session.disposedafterdispose()is the same in-flight-load guard shape (if (session.disposed) return;after anawait) bothflowerandhunteralready used before this package existed. -
Two settings popovers, not a row of toolbar controls.
@seer-project/mesh-viewer-ui’sMeshSettingsPanel(render mode/shading/ smooth/subdivide/texture-filter/reset-camera) andRenderSettingsPanel(render-quality tier — flat/cinematic/photo — shadows, cinematic’s bloom/ AO/DOF, photo-mode progress, and the screenshot/turntable export buttons) are two independent, sibling toolbar popovers, each with the sameattach(session)/refresh()/detach()/dispose()contract driven off the sameMeshSession. siren, flower, and chimera all wire both the same way — seemesh-viewer-ui’s own README for the full element/options contract and an integration example, andrender-quality-proposal.mdfor the design behind shadows/cinematic/photo mode/export themselves (implemented and independently reviewed — see that doc’s own status line for what shipped and what’s still a known gap). -
Cloth/hair spring-bone physics (
packages/engine-3d/src/spring-bones.ts) — a generic, engine-agnostic runtime simulation pass, not a reproduction of either requesting game’s real solver (Koei Tecmo’sNUNcloth sections and Unreal Engine’s APEX cloth/PhysicsAssetdata both stay genuinely undecoded).session.setSpringBones(opts)resolves a config in priority order — an explicitopts.config→ a bakedSEER_spring_bonesglTFextraskey → (opts.autoDerive) a bone-name guess (deriveSpringBonesFromNames) → nothing — and registers oneviewport.addFrameHook, running after the animation mixer’sonFrameand before the render call.opts.skipAnimatedBones(defaulttrue) drops any chain a clip already keyframes, so the sim never fights baked cloth/hair motion.MeshSettingsPanelexposes an optionalspringBonesCheckbox/springBonesStrengthRangepair (both omitted by every existing consumer’s markup, so this is purely additive) — seemesh-viewer-ui’s own README for the element contract.session. stepSpringBones(dt)is the deterministic-export counterpart toTurntable.step(), sinceviewport.renderNow()skips every frame hook.
Scaffold-template support is intentionally not implemented yet. Only the
manifest shape (the five fields above) lives in
packages/create-seer-app/templates/viewer/shared.ts.eta — there is no .eta
template code anywhere in create-seer-app that
branches on type: "mesh"/"scene", imports @seer-project/engine-3d, or
renders a 3D canvas. Each consuming project is expected to hand-wire this
package into its own tools/viewer/viewer.ts — flower and hunter are
the two projects with existing hand-rolled three.js viewers slated to
migrate onto this package first, each as its own commit, independent of the
scaffold. Template support is deferred until more than one real migration
has settled what the integration shape should look like — abstracting a
scaffold template from a single example risks locking in the wrong shape.
Type reconciliation: one ManifestEntry, not two
Section titled “Type reconciliation: one ManifestEntry, not two”docs/weaknesses.md §6 flags that the scaffold shipped two different,
never-reconciled AtlasMeta shapes (src/data/GameData.ts.eta’s
uniform-grid shape vs. tools/viewer/shared.ts.eta’s packed-frame shape) —
and, relatedly, tools/viewer/viewer.ts.eta declared its own local
ManifestEntry interface rather than importing one from shared.ts.eta,
which also carried a near-duplicate, unused AssetEntry type.
This revision reconciles the ManifestEntry/AssetEntry half of that:
ManifestEntry (with the new type and indexedPng fields) now lives once,
canonically, in shared.ts.eta, and viewer.ts.eta imports it instead of
redeclaring it. The dead AssetEntry type and the dead, uncalled
rgbaFromPalette() helper (also flagged as scaffold-inherited dead code —
viewer-tooling-review.md “What’s common” §8) were removed from
shared.ts.eta at the same time.
Deliberately left alone: the separate, larger GameData.ts.eta (uniform
grid) vs. shared.ts.eta (packed frames) AtlasMeta conflict weaknesses.md
§6 also describes. Those two types serve genuinely different consumers
(src/data/AssetLoader.ts’s generic loadAssets() schema vs. the viewer’s
atlas format) and reconciling them would mean changing GameData.ts.eta
and its runtime consumer, which is out of scope for a viewer-tooling change
and risks breaking the unrelated runtime-asset-loading example. Tracked as a
follow-up, not fixed here.
Why this doc isn’t vendored into scaffolded projects
Section titled “Why this doc isn’t vendored into scaffolded projects”This guide has never been vendored: the scaffolded README.md.eta has
always pointed at https://github.com/Shaid/seer/blob/main/docs/viewer.md
(the same link-not-copy approach crawl/README.md already uses for it,
independently of the scaffold) rather than copying it into a new project’s
docs/ folder. That was deliberate, not an oversight — common-tooling- candidates.md §15a flagged that architecture-overview.md and
boilerplate-guide.md, which were copied verbatim
(docs/architecture-overview.md.eta, docs/boilerplate-guide.md.eta — plain
copies, no template variables) into every scaffolded project, were a real
problem: three of six real sibling projects carried byte-identical stale
copies, one carried a 57-line-stale fork, and none would ever receive a
framework update after the point they were scaffolded. §15a’s own
recommendation was “the scaffold should link, not copy,” and this doc’s own
placement was written to set that precedent before generalizing it.
Update: architecture-overview.md.eta and boilerplate-guide.md.eta
have since been deleted, and project.ts no longer writes a local
docs/architecture-overview.md/docs/boilerplate-guide.md at all — a
scaffolded project’s README.md now links out to all three framework docs
the same way, and a fresh scaffold’s docs/ folder doesn’t exist until the
project adds its own game-specific docs to it. §15a is resolved, not just
diagnosed.