Skip to content
Work in progress. These docs describe Spawnite at launch, and some parts are still being built.

The model viewer

The model viewer draws a GLB or VRM file the way a game draws it: inside the engine’s Game, under one of its looks, with its sun, sky, fog and post-processing. A team member drops a file on tools.spawnite.com/model and checks the model before it goes into the asset library or a game. The viewer is the @spawnite/model-viewer package, which the asset store’s detail page mounts too; the page around it is the @spawnite/tools app.

Two more tools sit beside it, each answering a question the team asks of an asset: which of two files to keep, and how a texture reads under the light. The viewer answers a third itself: how a clip looks on an avatar. This page is the design draft for the viewer and the tools app. The asset store page describes the store’s page around the viewer.

Unity’s preview window, Unreal’s static mesh editor, Godot’s import preview and PlayCanvas’s model viewer each draw a model with the engine’s own renderer, so the preview matches the game. Phaser is 2D and has none. The three.js editor, Babylon’s sandbox and Google’s <model-viewer> take a dropped file in a web page, each with its own lighting. This viewer takes the engines’ side on the renderer and the web tools’ side on the drop. It differs from <model-viewer> because a viewer with its own lighting does not show what a game shows.

  • The file never leaves the browser. The page hands the dropped or picked file to the viewer as a blob URL, and reads its budget from the same bytes. The app has no sign-in and reads no team data.
  • The engine draws the model. ModelViewer mounts Game and one Scene with loadingScreen={false}, and the scene mounts Lighting with the chosen look over the model, with no World and no map. The engine’s useModel loads the file with its own Draco, meshopt and VRM loaders.
  • The model stands as the engine stands it. Its lowest point sits on the floor, and its origin stays where it stands across the ground. A VRM 0 avatar turns to face +Z, as the engine turns one. A model that is off its origin shows off its origin here too.
  • The look switch holds the engine’s looks. Morning, Noon, Dusk, Night and Overcast are the looks under @spawnite/engine/looks/, and None draws the daylight rig with no look. Noon opens.
  • The camera switch holds two cameras. Orbit frames the model and stays in front of it: a sideways drag turns the model under a still floor and light, as the character screens of Roblox and Fortnite do, an up-and-down drag tilts the camera, and it zooms and pans. Game stands where the engine’s camera opens on a character, at its lens, and turns about the character’s aim at that distance, so the model shows at the size a player sees it.
  • The floor is a map’s ground. The engine’s grid texture, one square to the metre, takes the model’s shadow. The floor toggle hides it.
  • The budget reads the file, not the scene. The panel reads the file with @gltf-transform/core, as spawnite model check does: its triangles, counted as the bake counts them, against the triangle budget that @spawnite/schema holds and the bake warns over; its draw calls, one for each primitive a node draws; its textures, by kind and size; and the file’s size. A count over its limit turns red.
  • A rigged model plays its clips. The viewer lists the file’s clips with ClipPicker, a card for each with its name over its length, in one radio group the arrow keys move along, and scrubs the one playing with ClipTimeline, both from @spawnite/platform-ui. The time under the track reads to the hundredth of a second, and the scrub moves by that much, so a frame can be named. A scrub holds the frame it lands on, as a drag of Unity’s or Unreal’s playhead does, and play runs the clip on from there. Loop is an IconButton at the timeline’s left, before play and pause, filled while it is on as a selected card is. The clips sit in the viewer’s panel, with the timeline at the panel’s very bottom, or, when a page draws the viewer with no panel, in a row under its canvas. The first clip plays on load. Beside the canvas the panel takes two fifths of the box, at most 320 px, so the model always has more room than its controls.
  • A page that only shows a model gets the plain layout. ViewerLayout.Plain draws the canvas alone: no buttons, no clip strip, no hint, and a drag that turns the model. The page names the clip to open on with clip, such as idle, and the viewer falls back to the file’s first clip. The player app’s character page uses it.
  • A creator’s page gets the overlay layout. ViewerLayout.Overlay draws no panel: the button row and the channel switch sit at the canvas’s top right on the same glass, and one low strip at the bottom holds Loop and play with the track beside them, the clips as pills of their names under the buttons and the time at the right. No look switch, no camera switch and no budget: one look and one camera serve a creator, and the asset store lists the file’s facts under the viewer. The Panel layout is the team’s, on the tools app.
  • The channel switch, reset and fullscreen. The channel switch shows the file’s materials, the surface’s normals as Bumps, or a wireframe. The model stands still until a drag turns it. Reset frames the model again and turns it back. Fullscreen shows the whole viewer.
  • A hint on the first visit. One line at the foot of the canvas says how to move the view: drag to turn, right-drag to move and scroll to zoom with a mouse, or two fingers to move and a pinch to zoom on a touch screen. The Game camera only turns, so its line says only that. The first press or scroll on the canvas removes it, and the browser remembers, so it never shows again. On a narrow canvas it stands above the page’s buttons. Sketchfab puts the same controls behind a ? button; a hint that goes after one drag costs a creator no click.
  • Copy a picture puts the canvas on the clipboard. The button copies the model as drawn, as a PNG at the canvas’s size, with no buttons over it, so a team member pastes it into a chat with an agent or into an issue. It works on the deployed page and needs no server. Babylon’s sandbox and Sketchfab save a file instead; the clipboard leaves out the step of finding the file.
  • The viewer is not a game. It mounts Game with menu={false} and loadingScreen={false}, so no Play menu opens over the model and Escape stays the page’s, and it draws the file with the engine’s ModelFile, as a page that is not a game says. The tools app loads the engine’s plugin with loadingScreen: false.

A VRM avatar dropped on /model opens with four clips from @spawnite/assets: Idle, Run, Jump and Land, with Idle playing. They show in the panel’s clip row as a rigged GLB’s own clips do, with the timeline and Loop under it. Each clip is a card with its name over its length, and Add a clip is a card of the same size, so the row reads as one set. The row ends in Add a clip, which takes a .vrma dropped on it or picked with it, and the clip joins the row and plays. A GLB plays its own clips, and gets no Add a clip.

Unity’s animation preview lets a user drag any humanoid model into a clip’s preview, and Unreal’s animation editor has a preview mesh picker. Both exist because a clip is judged on the body that will wear it. This viewer turns it around: the body is the file, and the clips come to it, so one drop answers how the avatar moves.

  • The viewer plays what the engine plays. The engine plays a VRMA clip on a VRM avatar and nothing else. The viewer retargets nothing between rigs that differ.
  • Each clip is made as the engine makes one. ModelViewer takes the clips as its animations prop. It loads each file with the engine’s VRM loader, binds the motion to the avatar’s humanoid bones with createVRMAnimationClip, and plays it on a mixer, as the engine’s VRM view does, with no World and no CharacterSpawn. The clips replace the file’s own in the panel, in the order the page lists them, and the first plays on load.
  • Add a clip hands the page the file. ModelViewer takes onAddClip, and with it a VRM’s clip row ends in its card. The page makes the file’s blob URL and adds it to animations, as it does for every file it opens; the viewer plays the new clip once it is made.
  • A file that is not a clip says so. The viewer shows “The clip is not a VRMA this avatar can play.” over the avatar, which keeps playing the clip it had, and the file does not join the row.
  • A clip’s marks sit on its track. A ViewerAnimation carries marks, seconds by name as the game’s registerClip takes them, and the timeline draws each as a tick with its name above it, as Unity’s animation window draws a clip’s events and Unreal’s montage editor its notifies. A person scrubs to the tick, checks the frame, and reads the second under the track; the viewer writes nothing, so the agent writes the mark in the game.
  • An address opens an avatar with a clip and its marks. /model?avatar=chifa&clip=charged-spell-cast&marks=release:1.4 opens the named avatar from @spawnite/assets, puts the named clip first in the row with the marks given, and plays it; the four bundled clips follow. An agent hands the link to the person who confirms a mark, and the person says the second to write. A name the package has no file for shows as a line over the page. With no clip named, the marks go on each clip added by hand, so a clip outside the package is checked the same way. The app bundles every avatar and clip under the package, and a page fetches the ones its address names and the four bundled clips, none of the rest.

/compare shows two models side by side, so a team member picks which of two files to keep: a bake against its source, or two exports of one model. Each side is a drop target, then its canvas, with Copy a picture and Open another over it. One panel on the right sets both: the look, the view, the floor and reset, once, and each file’s budget in its own column under the file’s name. A file with clips of its own, such as a rigged GLB, keeps a clip row under its canvas, since each file’s clips are its own to pick. On a phone, the two canvases stack and the panel goes under them.

Unreal’s static mesh editor compares levels of detail in one viewport. This tool differs from it: two viewers that share one look and one camera, because the question is which file to keep, not how one file changes with distance.

  • The two viewers share a link. createViewerLink() makes one, and each ModelViewer takes it as its link prop. Every viewer on a link shows the same look, camera, channel and floor, and turns its model with the others. The one panel’s switches change both.
  • One panel for both. A ModelViewer with layout={ViewerLayout.Canvas} draws its canvas alone, with its clip row under it when the file has clips, and SharedViewerPanel draws the panel for every viewer on a link, with one budget column per file. A second copy of each switch said nothing the first did not.
  • One camera leads. The viewer a team member last dragged, or the last one framed, leads, and the other’s camera copies its place and aim every frame.
  • One file shows in both. The engine’s loader caches one parse per URL, and a parse’s scene draws in one canvas only, so each viewer after the first loads the file under a fragment, as the engine loads a second wearer of one avatar.

/texture tiles an image across a four-metre plane of ground under the engine’s light. The panel holds the look switch; the tiling, one, two, four or eight repeats a side; a channel switch of Colour, Alpha, Red, Green and Blue; and the image’s size in pixels, its kind and its file size. It takes PNG, JPEG and WebP.

Unity’s and Unreal’s texture previews show the image flat, with channel switches and a tiling preview. This tool matches them and adds the engine’s light, because a texture that reads well flat can read wrong lit.

  • The plane wears the texture as a game does. A lit standard material samples it in sRGB, repeated both ways.
  • A channel shows in grey. Red, Green and Blue show as the file stores them. Alpha is stored linear, so the shader decodes it to show as stored too.
  • The viewer is TextureViewer. It mounts Game and one Scene as ModelViewer does, with Copy a picture and Fullscreen over the canvas.

The tools app is a row: a toolbar down the left edge with one link per tool, each naming its tool in a tooltip to its right, then the tool. Each tool fills the rest of the page with its drop target, and with its viewer once a file is open. A viewer is the canvas with its buttons along the top right, each naming itself in a tooltip, and Open another at the bottom left, beside a panel of the look, the view, the clips, which end in Add a clip on a VRM, and the budget last. The panel is 384 px wide, so the camera and channel switches share a row, and so do four clip cards. The clip cards are 64 px wide at least, with the name over the length, centred. A viewer lays out by its own width, not the screen’s: narrower than 672 px, as on a phone, the panel moves under the canvas and takes at most half its height. The compare tool’s canvases have no panel of their own, and its one panel moves under them on a phone.

The app is Vite, React and TanStack Router, drawn with @spawnite/platform-ui, as the creator app is. Its web manifest lets a browser install the page to open in its own window. A Cloudflare Worker serves it as static files on tools.spawnite.com, at /model, /compare and /texture.