Quick start — two things are all you need:
1. API key: YOUR_API_KEY
2. curl -H "Authorization: Bearer YOUR_API_KEY" https://www.genjicrowd.com/v1/crowd/cr_sample01
Genji Crowd API — v1
Partner integration reference for Tuanjie / Unity native rendering.
Versioning
This document describes API v1. The version is reflected in the URL prefix (/v1/).
Breaking changes (removed or renamed fields, changed semantics) will increment the version
to /v2/, and v1 will be announced deprecated before removal.
Additive changes (new optional response fields) do not change the version.
Overview
The Genji Crowd API lets your engine load a saved crowd recipe — the complete configuration needed to reproduce a crowd authored in the Genji editor — together with pre-resolved per-character part assignments and direct asset URLs.
Typical integration flow:
GET /v1/crowd/{crowd_id}
→ recipe (crowd configuration)
→ characters[] (body / face / hair IDs, positions, animation state)
→ assets (GLB URLs, animation GLB URLs)
Your engine then:
- Downloads
assets.skeleton_glbonce (contains all part meshes on a shared skeleton). - For each entry in
characters[], selects the named mesh nodes and applies per-character colour, scale, and animation phase. - Runs your own agent simulation using the recipe's boundary / collision params as inputs.
Asset hosting
All GLB and animation files are served from the host that answered your request. Asset URLs are fully resolved and returned in every API response — you never construct them yourself.
| Environment | Where assets are served from |
|---|---|
| Local dev | The server's own host (e.g. http://localhost:3001) |
| ngrok tunnel | The tunnel URL (e.g. https://genji-api.ngrok.app) |
| Production | Configured by the deployment team (Cloudflare R2 / CDN) |
Never hardcode asset URLs. Always read them from
response.assets.*. The server constructs fully-resolved URLs at request time, reflecting wherever it is currently running. Copy-pasting an example URL from this document into your code will point at the wrong host. Useassets.skeleton_glb,assets.animations.walk, etc. directly.
Authentication
Every request must carry a Bearer token in the Authorization header:
Authorization: Bearer gk_live_…
Keys are issued from the Genji dashboard (Settings → API Keys) or by the deployment team. The raw key is shown once on creation and never stored in plaintext.
Error responses:
| Status | Body | Cause |
|---|---|---|
401 |
{"error":"Missing Authorization: Bearer <api_key>"} |
Header absent |
401 |
{"error":"Invalid or revoked api key"} |
Key wrong or revoked |
404 |
{"error":"Crowd not found"} |
crowd_id does not exist |
Endpoint
`GET /v1/crowd/{crowd_id}`
Returns the full recipe, pre-resolved character list, and asset URLs for one saved crowd.
Path parameter
| Param | Type | Description |
|---|---|---|
crowd_id |
string |
Opaque ID, format cr_[a-zA-Z0-9]{8} |
Response `200 OK` — `application/json`
{
"crowd_id": "cr_sample01", // string — matches request path
"name": "Imperial Crowd — Tuanjie Sample",
"collection": "genji-v1", // string — collection tag (may be "")
"recipe": { ... }, // object — see Recipe Schema below
"characters": [ ... ], // array — see Character Object below
"shared_parts": ["Eye_L","Eye_R","L_Hand","R_Hand","L_Foot","R_Foot"],
"assets": { ... }, // object — see Asset URLs below
"updated_at": "2026-06-24 10:00:00" // string — ISO-ish UTC timestamp
}
Recipe Schema
recipe is a plain JSON object with the following fields.
Core
| Field | Type | Description |
|---|---|---|
v |
integer |
Recipe version. Currently 1. |
seed |
integer |
Master RNG seed. Determines all character assignments and initial positions deterministically. |
size |
integer |
Total number of characters in the crowd. |
weights |
object |
Bucket weight map — { bucketId: number }. Controls the mix of costume archetypes. See Capabilities for bucket IDs. |
Walk / animation
| Field | Type | Description |
|---|---|---|
walkSpeedMin |
float |
Minimum walk-speed multiplier (relative to clip speed). Default 0.6. |
walkSpeedMax |
float |
Maximum walk-speed multiplier. Default 1.0. |
walkRatio |
float |
Legacy field (superseded by pace). |
pace |
float 0–1 |
Fraction of characters that walk vs. stand idle. 0 = all idle, 1 = all walking. Default 0.5. |
crowdRate |
float |
Characters per minute entering in flow/road mode. |
Layout
| Field | Type | Description |
|---|---|---|
boundaryShape |
string |
Crowd boundary shape: "circle", "square", "road", "donut", "spline". |
boundaryRadius |
float |
Outer boundary radius in metres. For road, this is the half-length. |
spawnRadius |
float |
Inner spawn radius in metres. For road, this is the half-width. |
splinePoints |
[[x,z], …] | null |
Control points for spline shape (world-space XZ). null for other shapes. |
facing |
float 0–1 |
0 = characters face random directions, 1 = all face the same direction. |
distribution |
string |
"random" or "grid". Controls how positions are distributed inside the boundary. |
Terrain
| Field | Type | Description |
|---|---|---|
terrain.active |
boolean |
true = procedural terrain active. |
terrain.amplitude |
float |
Terrain height amplitude in metres. |
terrain.frequency |
float |
Terrain noise frequency. |
terrain.noiseType |
string |
"fractal" or "simplex". |
Colour variation
All garment colours are bucket-specific HSL values jittered per character using these ranges.
| Field | Type | Description |
|---|---|---|
hueShift |
float |
Global hue rotation applied to all characters' garments. |
hueVar |
float |
Per-character hue jitter range ±hueVar. |
satVar |
float |
Per-character saturation jitter range ±satVar. |
lightVar |
float |
Per-character lightness jitter range ±lightVar. |
Collision / avoidance
These parameters feed your own agent simulation engine.
| Field | Type | Description |
|---|---|---|
collisionRadius |
float |
NPC body radius multiplier (× character height). Two characters are "touching" when their distance = 2 × radius × height. |
personalSpace |
float |
Centering / path-following force strength (0–1). |
anticipation |
float 0–1 |
How early NPCs start steering away from collisions. 0 = react only when touching, 1 = steer early. |
Obstacles
"obstacles": [
{ "type": "rock", "x": 20.0, "z": 15.0, "radius": 3.5 },
...
]
Each obstacle is a cylinder (infinite Y) in world-space XZ. Characters avoid a disc of radius metres centred at (x, z).
Effectors
| Field | Type | Description |
|---|---|---|
groupingMode |
boolean |
When true, characters cluster into groups. |
attractActive |
boolean |
Whether the global attractor is active. |
attractTarget |
{x, z} |
World-space XZ position of the attractor. |
Render flags
These are viewport toggles from the authoring tool. Use them as hints for your renderer.
| Field | Type | Description |
|---|---|---|
textures |
boolean |
Author had textures on when saving. |
collisionEnabled |
boolean |
NPC–NPC avoidance enabled. |
jogBlendEnabled |
boolean |
Walk→jog blend enabled. |
lazyGPU |
boolean |
Internal GPU update optimisation flag. |
collection |
string |
Collection tag (e.g. "genji-v1"). |
Character Object
Each entry in characters[] is one NPC with pre-resolved part IDs.
{
"index": 0, // integer — 0-based character index
"bucket": "soldiers", // string — archetype bucket ID
"body_id": "M_Terracotta_Infantry_Soldier",// string — SkinnedMesh node name in skeleton_glb
"face_id": "M_Chinese_Face_03", // string — SkinnedMesh node name in skeleton_glb
"hair_id": "Ming_Warrior_Crown", // string — SkinnedMesh node name in skeleton_glb
"x": 12.3400, // float — initial X position (metres, world space)
"z": -5.7100, // float — initial Z position (metres, world space)
"rot_y": 1.2340, // float — initial Y-axis rotation (radians) — spawn facing, NOT a movement vector (see below)
"scale": 1.0230, // float — uniform scale multiplier
"phase": 0.6710, // float 0–1 — animation phase offset (avoids lockstep)
"anim_clip": "walk", // string — "idle" or "walk" at spawn
"idle_variant": 2, // integer 0–7 — which idle animation variant (when anim_clip="idle")
"garment_hsl": [0.0521, 0.5182, 0.4830], // [H, S, L] normalised 0–1 — per-character garment tint
"skin_hex": "#ddb892", // string — skin colour hex
"hair_hex": "#1a1410" // string — hair colour hex
}
Shared parts
In addition to body_id, face_id, and hair_id, every character also renders the
six shared mesh nodes (eyes, hands, feet). These are the same for all characters
and listed in the top-level shared_parts array:
["Eye_L", "Eye_R", "L_Hand", "R_Hand", "L_Foot", "R_Foot"]
Coordinate system
All positions, distances, and rotations in the API response use the following convention:
| Property | Value |
|---|---|
| Handedness | Right-handed |
| Up axis | Y |
| Units | Metres |
| Ground plane | XZ (Y = terrain height, 0 on flat ground) |
rot_y = 0 |
Character faces +Z |
Positive rot_y |
Counter-clockwise rotation viewed from above (+Y) |
Character positions are given as (x, z) pairs (Y is always ground height).
Place the character at world position (x, groundHeight, z) and apply a Y-axis rotation
of rot_y radians.
The direction vector for a character's facing direction is:
forward_x = sin(rot_y)
forward_z = cos(rot_y)
This matches the GLB assets: skeleton_glb and all animation GLBs are exported in
glTF convention (right-handed, Y-up). No axis-swap is needed when loading them.
Everything is metres, out of the box. The
x/zvalues in the API response (character positions) are in metres, and the GLB scene loads in metres too: the source skeleton is Maya centimetres, but the export bakes a 0.01 scale into the GLB's root node. Do not apply any unit conversion — a loaded character stands ~1.7 m tall as-is. Sanity check after loading: ~1.7 tall = correct; ~170 = your importer stripped the root-node scale (apply 0.01 at the root yourself); ~0.017 = you applied an extra 0.01 that must be removed.
Unity / left-handed engines: Unity uses a left-handed Y-up system. To convert: negate
z→position.z = -C.z, and negaterot_y→rotation.y = -C.rot_y. Also negateforward_zwhen converting the direction vector.
rot_yis a spawn-time facing snapshot, not a movement vector. It is generated once per character based on the recipe's spawn shape (e.g. for a circle boundary it is effectively random). If you drive your own movement (custom paths, a street the character walks down, steering/avoidance) — compute rotation from your own velocity every frame, don't reuse the API's staticrot_yfor a character you are moving yourself:rot_y = atan2(velocity.x, velocity.z). Only use the API'srot_yas-is foridlecharacters (never move) or if you are reproducing the exact spawn layout the recipe describes without adding custom movement.
Asset URLs
// ⚠️ These URLs are returned by the server — never constructed by the client.
// The host changes per environment (local dev / ngrok / production CDN).
// Always use the value from the response; never hardcode the base URL.
"assets": {
"unit": "m",
"unit_scale": 1.0,
"skeleton_glb": "{base}/assets/Basemesh31.glb",
"lod_glb": "{base}/assets/Basemesh31_LOD1.glb",
"capabilities_json": "{base}/v1/capabilities",
"animations": {
"idle": "{base}/assets/A_Idle.glb",
"idle_arms_crossed": "{base}/assets/A_Idle_ArmsCrossed.glb",
"idle_hands_hip": "{base}/assets/A_Idle_HandsHip.glb",
"idle_clasp": "{base}/assets/A_Idle_Clasp.glb",
"idle_shift": "{base}/assets/A_Idle_Shift.glb",
"idle_look_around": "{base}/assets/A_Idle_LookAround.glb",
"idle_scratch": "{base}/assets/A_Idle_Scratch.glb",
"idle_tired": "{base}/assets/A_Idle_Tired.glb",
"walk": "{base}/assets/A_WalkFwd.glb",
"jog": "{base}/assets/A_JogFwd.glb",
"walk_stop": "{base}/assets/A_WalkFwd_Stop.glb"
}
}
The GLB scene loads in metres as-is. The source skeleton is Maya centimetres, but
the export bakes a 0.01 scale into the GLB's root node, so no conversion is needed —
unit_scale is 1.0 and exists only so SDKs read the value instead of assuming:
gltf.scene.scale.setScalar(assets.unit_scale); // 1.0 — a no-op, but future-proof
Sanity check after loading: measure the height of a visible character.
- ~1.7 — correct, do nothing.
- ~170 — your importer stripped the root-node scale; apply
0.01at the root yourself. - ~0.017 — you applied an extra
0.01on top of the baked scale; remove it.
Never adjust position/Y to compensate for scale problems. The skeleton root is
authored at the character's feet (local origin = ground contact point), so placing the
root at world (x, 0, z) needs no additional Y offset. If characters float above or
sink below the ground, the root transform got lost in cloning/re-parenting — re-check
the clone order instead of adding a foot-height offset.
How part IDs map to assets
All character parts live inside a single GLB — skeleton_glb.
Each part ID (e.g. M_Terracotta_Infantry_Soldier) is the name of a SkinnedMesh node
inside that file. To render a character:
- Load
skeleton_glbonce. It contains one shared skeleton and oneSkinnedMeshnode per part. - For each character in
characters[], make the following mesh nodes visible:body_idnodeface_idnodehair_idnode- All 6
shared_partsnodes (eyes, hands, feet) - All other mesh nodes: hidden
- Instance or clone the visible set, applying the character's
scale,rot_y, and(x, z)position. - Apply per-character colour (see Colour application below).
- Play the animation clip named by
anim_clip, offset byphase × clip_duration.
lod_glb is a second, independent GLB — same node names, same shared skeleton, same
scale/origin behaviour as skeleton_glb, just lower-polygon meshes (~40% the file size).
It is not a mesh variant inside skeleton_glb; load it separately and clone from it the
same way. For a crowd of this scale, a reasonable default is: full-res skeleton_glb
inside ~15–20 m of the camera, lod_glb beyond that.
You do not need two live characters per NPC. Keep exactly one active clone per
character at a time — swapping is cheap: when a character crosses the LOD distance
threshold, replace its clone (hi-res ↔ lod), but carry over the current clip and
playback time onto the new clone's mixer (newAction.time = oldAction.time) instead
of restarting at 0. That's the only thing "don't hot-swap mid-animation" means — preserve
timing across the swap. It does not mean run both resolutions simultaneously; doing
that doubles memory and animation cost for every character, all the time, for a distance
check that only a fraction of characters need at once.
capabilities_json (GET /v1/capabilities) lists the available shapes, the bucket
ids/labels you can weight, and every part's id, label, group, and gender. Useful for
building a UI or validation tool.
Colour application
Each character has three colour inputs:
| Input | Source | Apply to |
|---|---|---|
garment_hsl |
Per-character HSL (0–1 normalised) | Body mesh — set directly: material.color.setHSL(h, s, l) |
skin_hex |
Hex string | Face + shared skin meshes |
hair_hex |
Hex string | Hair mesh |
garment_hsl is the complete, final garment colour, computed server-side. Apply it
directly with material.color.setHSL(h, s, l) — do not derive or composite it from
any other source.
GPU-instanced rendering (bone texture)
The default rendering path described above (SkeletonUtils.clone() + one
AnimationMixer per character) is simple but does not scale — expect it to fall below
interactive framerates somewhere in the 150–300 character range depending on
hardware (confirmed in practice: 17–24 fps at 150–5000 individually-cloned characters).
For crowds of 1000+ characters, use assets.bone_tex_bin + assets.bone_tex_manifest
instead: a single shared GPU texture encoding every animation clip's bone matrices,
sampled per-vertex in a custom skinning shader, rendered via one THREE.InstancedMesh
per body/face/hair part (not per character) — typically ≤36 draw calls for the whole
crowd, regardless of size. This is the same technique the Genji crowd-authoring tool
itself uses internally to render thousands of characters at full framerate.
"assets": {
"bone_tex_bin": "{base}/assets/bone_tex.bin",
"bone_tex_manifest": "{base}/assets/bone_tex_manifest.json"
}
A ready-to-use reference implementation ships at /sdk/genji-crowd-instanced.js
(self-contained ES module, only peer dependency is three), with a live example at
/sdk/genji-crowd-instanced-demo.html (append ?size=2000 to control crowd size).
How it works
bone_tex_bin is a raw Float32Array binary (no header, no compression) laid out as an
RGBA32F texture: width = bone_tex_manifest.texWidth, height = bone_tex_manifest.texHeight.
Each animation clip occupies a contiguous range of texture columns (one column per baked
frame, 30 fps); each bone occupies 4 texture rows (one mat4, stored as 4 vec4 rows).
Your shader samples this texture per-vertex, blends the 4 influencing bones per
skinWeight, and reconstructs the same skinned position SkeletonUtils.clone() would
have computed on the CPU — just done once on the GPU for every instance in a part,
instead of once per cloned character on the CPU.
You do not need to understand this to use it — loadBoneTexture() in the reference SDK
builds the THREE.DataTexture for you, and the shipped shader does the sampling.
bone_tex_manifest fields
| Field | Type | Meaning |
|---|---|---|
texWidth / texHeight |
int | Dimensions of bone_tex_bin's RGBA32F texture. |
nBones |
int | Bone count (matches skeleton_glb's skeleton). |
bakeFps |
int | Frame rate the animation was baked at (30). |
format |
string | Always "RGBA32F" — 4 floats per texel, no compression. |
totalBakedFrames |
int | Total frame count across all baked clips. |
numIdle |
int | Count of idle variants (indices 0..numIdle-1, matches idle_variant). |
clipWalk |
int | Clip index to use for anim_clip === "walk". |
clipInfo |
object | Per-clip {start, frames, duration}, keyed by clip name. |
modelScale |
number | Informational — already baked into the texture; do not reapply. |
Minimal integration
import { loadParts, loadBoneTexture, buildCrowdInstances, updateCrowdTime }
from '/sdk/genji-crowd-instanced.js';
const parts = await loadParts(response.assets.skeleton_glb);
const boneTex = await loadBoneTexture(response.assets.bone_tex_bin, response.assets.bone_tex_manifest);
const crowd = buildCrowdInstances(scene, parts, boneTex, response.characters, response.shared_parts);
function animate(dt) {
updateCrowdTime(crowd, dt);
renderer.render(scene, camera);
}
Scope: no per-character speed variation
This rendering path implements exactly the public Character schema
(anim_clip/idle_variant/phase/garment_hsl/skin_hex/hair_hex/x/z/rot_y/scale)
— there is no speed or momentum field in the API, so walk always plays at its authored
clip speed. Characters vary only by phase (stagger), not cadence. This is a known,
accepted trade-off for crowd-scale rendering, not a bug: a wall of 2000 walkers reads
slightly more uniform than a hand-tuned simulation with per-NPC speed variation.
Common integration failures (bone texture path)
| Symptom | Cause | Fix |
|---|---|---|
| All characters share one pose / T-pose | Rendered before loadBoneTexture() resolved |
await it before calling buildCrowdInstances |
| Characters never animate (frozen) | uTime uniform never advanced |
Call updateCrowdTime every frame |
| Every character plays the same clip/pose | Per-instance aClip/aPhase not written |
Use writeCrowdInstances — don't hand-roll instance buffer writes |
| Jog/run animation requested but not playing | Not available — the public API only exposes idle/walk | N/A — jog is internal-only, not part of this API |
| More than ~36 draw calls | Building one InstancedMesh per character instead of per part |
One mesh per unique body_id/face_id/hair_id/shared-part value, not per character |
Super-optimized render tiers (untextured / VAT / BBM)
The bone-texture path above already renders the whole crowd in ~36 draw calls at full framerate. For extreme scale (tens of thousands of characters) or memory / GPU-bound targets (mobile, VR, integrated GPUs), three further tiers trade visual fidelity for raw throughput. These are the same optimizations the Genji tool uses internally.
All three consume exactly the data the API already returns — assets.skeleton_glb and
assets.bone_tex_bin + assets.bone_tex_manifest. There are no extra endpoints and no
extra downloads; each tier is a rendering choice you make client-side. They can also be
mixed as LOD bands (full mesh near the camera, BBM far), and because every tier reads
the same bone texture, a character's animation stays perfectly in sync across a tier swap.
At a glance
| Tier | Draw calls | Per-vertex work | Extra VRAM | Use when |
|---|---|---|---|---|
| Full skinned (bone tex) | ~36 | 4-bone fetch + weighted blend | part textures | default; near camera |
| Untextured | ~36 | 4-bone blend, no map fetch | none (frees albedo) | memory-bound; stylized; far band |
| VAT | ~36 | 2–8 fetches, no blend | large (~2.4 GB full cast) | ALU / skin-blend bound |
| BBM cube proxy | 1 | 1 fetch + 1 matrix multiply | none | extreme scale; far LOD |
1. Untextured (flat) mode
Skip loading the part albedo/normal/roughness maps entirely and render each part as a flat lit colour (white, or a per-part solid tone). This frees all texture VRAM and removes every texture fetch from the fragment shader — the cheapest change here, and a good default for a distant LOD band or a memory-limited device.
- Don't bind any
map/normalMap/roughnessMap; give the material a plain colour. - For a clean white look on a PBR material, add a small white emissive (~0.35) so the mid-tones lift out of grey; a flat/unlit material doesn't need it.
- This is orthogonal to VAT and BBM — you can render untextured in any tier. The
recipe.texturesflag tells you whether the author had textures on when saving.
2. VAT — Vertex Animation Texture
Pre-bake the skinned vertex positions (the exact result the bone-texture shader computes)
into a texture indexed by [absoluteFrame, vertexID]. At runtime the vertex shader does a
plain lookup and lerps between two frames instead of blending four bone matrices:
// f0,f1 = the two frame columns bracketing the current time; fr = blend
vec3 pos = mix( texelFetch(uVAT, ivec2(f0, gl_VertexID), 0).xyz,
texelFetch(uVAT, ivec2(f1, gl_VertexID), 0).xyz, fr );
- Why: collapses per-vertex skinning from ~32–64 texture fetches to 2 (idle), 4 (walk/jog), or 8 (mid-crossfade). Best when the 4-bone blend — not draw calls or memory — is your bottleneck.
- Cost: VRAM. Every vertex × every baked frame is stored as a
vec3; the full cast is ~2.4 GB. It is baked once at load, client-side, from the samebone_tex_binthe API serves — it is deliberately not shipped pre-baked (far too large to transfer). - Bake: for each part geometry, for each baked frame in
bone_tex_manifest.clipInfo, run the bone-texture skinning once on the GPU and write each vertex's world position intoVAT[frame][vertexID]. Reusebone_texframe layout so clip indices/phasematch. - Scope: mutually exclusive with BBM (VAT keeps the real mesh; BBM replaces it).
3. BBM — bone-cube proxy (extreme-scale LOD)
Replace each character's skinned mesh with ~17 rigid primitives — one box/cylinder per bone (limb cylinders, two torso blocks, a head, hands) — each bolted to a single bone. No 4-bone blend: one bone-matrix fetch per vertex. The whole crowd merges into one instanced mesh → 1 draw call, with the cheapest animated vertex shader of any tier.
Build it once from the skeleton bind pose (skeleton_glb): for each limb bone, a
cylinder spanning that bone to its child; torso and head fitted from the hip/shoulder/head
bone positions. Author each primitive in its bone's local bind space, then tag every one
of its vertices with that single bone's index (aBone).
Runtime: fetch that one bone's animated matrix from the same bone texture and transform the vertex rigidly — a parent constraint, not skinning:
mat4 b = boneMatrix(aBone); // sampled from bone_tex (the shared texture)
vec3 world = (b * vec4(pos, 1.0)).xyz * UNIT_SCALE; // see the gotcha below
Critical ordering gotcha (learned the hard way). The bone texture bakes the cm→m
0.01model scale into every matrix (bone_tex_manifest.modelScale), and the cube geometry is authored in metres, so you compensate with a×100. Apply the×100to the transformed point, not to the input:(b * p) * 100, neverb * (p * 100). A uniform scale commutes with a matrix's rotation but not its translation — put it on the wrong side and the rotation still looks right but each bone's pose offset is crushed to 1%, so limbs detach and float for any bone far from bind pose (mid-stride calves, swinging arms). Everything looks fine in a bind-pose T-pose and only breaks once animated, which makes this bug easy to ship and hard to spot.
- Colour: the cubes have no textures; tint each primitive with the character's
garment_hsl(body/limbs),skin_hex(head/hands), andhair_hex. In untextured mode, render them flat white to match the full mesh. - When: beyond ~50 m, or crowds in the tens of thousands where only silhouette and motion read. Use it as the far LOD band under a full-mesh (or VAT) near band.
Animation clips
There are 8 idle variants (index 0–7, anim_clip = "idle") and 1 walk clip
(anim_clip = "walk"). Use idle_variant to select which idle GLB to play.
anim_clip |
idle_variant |
GLB key |
|---|---|---|
"idle" |
0 |
animations.idle |
"idle" |
1 |
animations.idle_arms_crossed |
"idle" |
2 |
animations.idle_hands_hip |
"idle" |
3 |
animations.idle_clasp |
"idle" |
4 |
animations.idle_shift |
"idle" |
5 |
animations.idle_look_around |
"idle" |
6 |
animations.idle_scratch |
"idle" |
7 |
animations.idle_tired |
"walk" |
(any) | animations.walk |
All clips share the same skeleton from skeleton_glb. Load and retarget once.
animations.walk has baked root-motion translation on the root bone, moving strictly
along local +Z (verified: root translation goes from [0,0,0] to [0,0,1.7] over
the clip) — the same direction a character with rot_y = 0 faces. If a character
appears to walk backwards, the clip is not the cause — the bug is in your own
position/rotation code (a swapped atan2 argument order, or an inverted sign when
deriving rot_y from a movement direction). Before patching rotation with a conditional
+ Math.PI flip, re-derive rot_y directly from your velocity vector using
rot_y = atan2(velocity.x, velocity.z) and remove any per-branch sign patch — a
conditional flip on top of a wrong formula usually looks "half fixed" rather than fully
fixed, because it's compensating for the wrong bug.
The phase field (0–1) is the normalised time offset into the clip at spawn time:
startTime = phase × clipDuration // in seconds
This prevents the whole crowd from being in lockstep.
Capabilities
GET /v1/capabilities (also linked from each crowd response as assets.capabilities_json)
is the catalogue you build requests and UIs from. It lists the available boundary shapes,
the bucket ids/labels you can reference in weights, and every part's id, label, group,
and gender. Structure:
{
"shapes": ["circle", "square", "road", "donut", "spline", "polygon"],
"buckets": [
{ "id": "soldiers", "label": "Soldiers" },
{ "id": "laborers", "label": "Laborers" }
// ... one entry per archetype you can weight
],
"parts": [
{ "id": "M_Terracotta_Infantry_Soldier", "label": "Terracotta Infantry Soldier", "group": "body", "gender": "m" },
{ "id": "M_Chinese_Face_01", "label": "Male Face 01", "group": "face", "gender": "m" },
{ "id": "Ming_Warrior_Crown", "label": "Ming Warrior Crown", "group": "hair", "gender": "m" }
// ... all body, face, and hair parts
]
}
Worked Example
Request
cURL
curl -s \
-H "Authorization: Bearer YOUR_API_KEY" \
https://genji-api.ngrok.app/v1/crowd/cr_sample01
JavaScript
const res = await fetch('https://genji-api.ngrok.app/v1/crowd/cr_sample01', {
headers: { 'Authorization': 'Bearer YOUR_API_KEY' },
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.json()).error}`);
const crowd = await res.json();
// crowd.assets.* — fully-resolved GLB URLs; pass directly to your loader
// crowd.characters[] — per-NPC part IDs, position, animation clip, colour
console.log(crowd.characters.length + ' characters');
console.log('Skeleton GLB:', crowd.assets.skeleton_glb);
Unity (C#)
using System.Collections;
using UnityEngine;
using UnityEngine.Networking;
public class GenjICrowdLoader : MonoBehaviour
{
[SerializeField] string apiHost = "https://genji-api.ngrok.app";
[SerializeField] string crowdId = "cr_sample01";
[SerializeField] string apiKey = "YOUR_API_KEY";
void Start() => StartCoroutine(LoadCrowd());
IEnumerator LoadCrowd()
{
using var req = UnityWebRequest.Get($"{apiHost}/v1/crowd/{crowdId}");
req.SetRequestHeader("Authorization", $"Bearer {apiKey}");
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success)
{
Debug.LogError($"[Genji] Crowd fetch failed: {req.error}");
yield break;
}
// crowd.assets.skeleton_glb — load with GLTFast / Piglet / custom GLB loader
// crowd.characters[] — per-NPC part IDs, position, animation, colour
string json = req.downloadHandler.text;
Debug.Log($"[Genji] Response: {json.Length} bytes");
// Next step: parse json into a CrowdResponse class and download assets
}
}
Response `200 OK` (abbreviated — full response has 150 characters)
{
"crowd_id": "cr_sample01",
"name": "Imperial Crowd — Tuanjie Sample",
"collection": "genji-v1",
"recipe": {
"v": 1,
"seed": 42,
"size": 150,
"weights": {
"laborers": 40,
"merchants": 10,
"soldiers": 35,
"court_officials": 5,
"emperor": 0,
"monks": 5,
"sages": 5,
"swordsmen": 0
},
"walkSpeedMin": 0.6,
"walkSpeedMax": 1.0,
"walkRatio": 0.65,
"pace": 0.5,
"crowdRate": 30,
"scaleVariation": 0.06,
"boundaryShape": "circle",
"boundaryRadius": 80,
"spawnRadius": 80,
"splinePoints": null,
"facing": 0,
"distribution": "random",
"terrain": { "active": false, "amplitude": 2.0, "frequency": 0.08, "noiseType": "fractal" },
"hueShift": 0.0,
"hueVar": 0.035,
"satVar": 0.18,
"lightVar": 0.12,
"collisionRadius": 0.4,
"personalSpace": 0.8,
"anticipation": 0.3,
"obstacles": [
{ "type": "rock", "x": 20, "z": 15, "radius": 3.5 },
{ "type": "rock", "x": -25, "z": 30, "radius": 2.8 }
],
"groupingMode": false,
"attractActive": false,
"attractTarget": { "x": 0, "z": 0 },
"textures": true,
"collisionEnabled": true,
"jogBlendEnabled": true,
"lazyGPU": false,
"collection": "genji-v1"
},
"characters": [
{
"index": 0,
"bucket": "soldiers",
"body_id": "M_Terracotta_Infantry_Soldier",
"face_id": "M_Chinese_Face_02",
"hair_id": "Ming_Warrior_Crown",
"x": 14.2317,
"z": -8.5901,
"rot_y": 3.9821,
"scale": 1.0184,
"phase": 0.7341,
"anim_clip": "walk",
"idle_variant": 5,
"garment_hsl": [0.0181, 0.5442, 0.4716],
"skin_hex": "#e8c8a0",
"hair_hex": "#1a1410"
},
{
"index": 1,
"bucket": "laborers",
"body_id": "Conscript_Laborer_Plain_Belted_Tunic",
"face_id": "M_Chinese_Face_04",
"hair_id": "Bamboo_Hat",
"x": -22.1083,
"z": 11.4452,
"rot_y": 1.2076,
"scale": 0.9812,
"phase": 0.3295,
"anim_clip": "idle",
"idle_variant": 2,
"garment_hsl": [0.0049, 0.5214, 0.5123],
"skin_hex": "#ddb892",
"hair_hex": "#251c14"
}
// … 148 more characters
],
"shared_parts": ["Eye_L", "Eye_R", "L_Hand", "R_Hand", "L_Foot", "R_Foot"],
// asset URLs reflect wherever the server is running — read them from the response
"assets": {
"skeleton_glb": "https://genji-api.ngrok.app/assets/Basemesh31.glb",
"lod_glb": "https://genji-api.ngrok.app/assets/Basemesh31_LOD1.glb",
"capabilities_json": "https://genji-api.ngrok.app/v1/capabilities",
"animations": {
"idle": "https://genji-api.ngrok.app/assets/A_Idle.glb",
"idle_arms_crossed": "https://genji-api.ngrok.app/assets/A_Idle_ArmsCrossed.glb",
"idle_hands_hip": "https://genji-api.ngrok.app/assets/A_Idle_HandsHip.glb",
"idle_clasp": "https://genji-api.ngrok.app/assets/A_Idle_Clasp.glb",
"idle_shift": "https://genji-api.ngrok.app/assets/A_Idle_Shift.glb",
"idle_look_around": "https://genji-api.ngrok.app/assets/A_Idle_LookAround.glb",
"idle_scratch": "https://genji-api.ngrok.app/assets/A_Idle_Scratch.glb",
"idle_tired": "https://genji-api.ngrok.app/assets/A_Idle_Tired.glb",
"walk": "https://genji-api.ngrok.app/assets/A_WalkFwd.glb",
"jog": "https://genji-api.ngrok.app/assets/A_JogFwd.glb",
"walk_stop": "https://genji-api.ngrok.app/assets/A_WalkFwd_Stop.glb"
}
},
"updated_at": "2026-06-24 10:00:00"
}
How to render this in your engine
1. GET /v1/crowd/{crowd_id} → get response R
2. Download R.assets.skeleton_glb once.
It contains one SkinnedMesh node per part, all bound to a single shared skeleton.
Sanity check: a loaded character stands ~1.7 m tall. Apply NO unit conversion
(see "Coordinate system" above).
3. For each character C in R.characters[]:
a. Clone the WHOLE loaded scene with a skeleton-aware clone
(three.js: THREE.SkeletonUtils.clone(gltf.scene)).
A plain .clone(true) leaves clones bound to the original skeleton —
they will all deform together or render in the wrong place.
b. Select mesh nodes: C.body_id, C.face_id, C.hair_id
+ all nodes in R.shared_parts
Hide all other SkinnedMesh nodes.
c. Place the character — transforms go on the CLONE WRAPPER ONLY:
position = (C.x, 0, C.z) ← Y from your terrain height at (x,z)
rotation = Quaternion.fromAxisAngle(Y, C.rot_y)
scale = Vector3(C.scale, C.scale, C.scale) ← size variation, ~0.95–1.05
Skinned meshes follow BONES, not their own node transform. Setting
position/scale on an individual SkinnedMesh does nothing. Transform
the cloned scene wrapper (the object the clone call returned), and
NEVER touch the inner node named "root" — it carries the baked
0.01 cm→m scale; overwriting it corrupts the character.
d. Apply colour (clone materials first so characters don't share tints):
body mesh → C.garment_hsl is the COMPLETE colour:
material.color.setHSL(h, s, l). No base-tint lookup.
face + hands + feet → C.skin_hex
hair mesh → C.hair_hex
e. Pick animation:
if C.anim_clip == "idle":
clip = idle_animation[C.idle_variant] ← see animation table
else:
clip = walk_animation
Clips are authored for R.assets.rest_reference_glb — retarget to the
skeleton before playing (see "Animation clips").
startTime = C.phase × clip.duration
f. Run your simulation:
Use R.recipe as configuration inputs for your agent system:
- boundary shape/radius → confinement area
- collisionRadius → NPC body size for avoidance
- personalSpace → centering / path-following strength
- anticipation → early-steer distance
- obstacles[] → static avoidance discs
- pace → fraction of walkers vs. idle standers
- walkSpeedMin/Max → per-NPC speed range (× clip speed)
Common integration failures (check here before debugging)
| Symptom | Cause | Fix |
|---|---|---|
| Character ~100x too small / invisible | You applied a 0.01 unit conversion | Remove it — the GLB is already metres |
| Character ~100x too large | Your importer stripped the GLB root-node scale | Re-apply 0.01 at the root, once |
| Character below floor / ignores position | Transform was set on a SkinnedMesh node | Transform the clone wrapper instead |
| All clones deform together / render at origin | Plain .clone(true) instead of skeleton-aware clone |
Use SkeletonUtils.clone |
| Body and hat/hair at different scales | Inner root node's scale was overwritten |
Never modify the inner root node |
| Only head/hat visible, no body | Same as above, or body node hidden by mistake | Check the root node scale and visibility list |
| All garments the same colour | A shared material instance was tinted | Clone the material per character before setHSL |
| Wrong garment colours | garment_hsl treated as a shift over a base tint |
It is the complete colour — apply directly |
| Characters float or sink | Y-offset added to compensate for a scale bug | Remove the offset; fix the root transform instead |
Payload scale
The v1 response includes the complete characters[] array with one entry per NPC.
Payload size scales linearly with crowd size:
recipe.size |
Approximate response size |
|---|---|
| 150 (sample) | ~55 KB |
| 1 000 | ~360 KB |
| 5 000 | ~1.8 MB |
| 10 000 | ~3.6 MB |
For crowds up to a few thousand characters this is fine over a LAN or fast connection. For very large crowds (10 000+), downloading and parsing the full list can add noticeable latency on first load.
Determinism
The same seed and the same request parameters always return the same crowd — the
same characters, positions, and colours, in the same order. You can rely on this for
caching and reproducibility: cache a response by its crowd_id + seed (+ size) and
re-request it freely. Generation runs server-side; you consume the resolved
characters[] array and render it directly.
Authentication
Every /v1 request requires your API key as a Bearer token:
Authorization: Bearer YOUR_API_KEY
Create and manage keys from your Genji Account → Keys page. Keep them secret — never commit them or embed them in client-side code.
Running locally
# 1. Start the API server
npm run server
# 2. Seed sample data (run once)
node --env-file=.env server/seed-api.js
# 3. Test
curl -s \
-H "Authorization: Bearer YOUR_API_KEY" \
https://genji-api.ngrok.app/v1/crowd/cr_sample01
To expose via ngrok for remote testing (do not run automatically):
ngrok http 3001
# Current tunnel: https://genji-api.ngrok.app
# Share https://genji-api.ngrok.app/v1/crowd/cr_sample01 with the integration team.
# Set ASSET_BASE_URL=https://genji-api.ngrok.app before starting the server.
Unity / Tuanjie
There are two layers of control.
Layer 1 — Design time (before loading)
The crowd is authored in the Genji editor and saved as a recipe. Your engineering team calls the API, receives the recipe and character list, then builds the crowd in Unity / Tuanjie. Any change to shape, colour, movement ratio, or obstacles means editing the recipe in Genji and re-fetching.
Cloning characters — use SkeletonUtils, not scene.clone()
All characters share one GLB. To place multiple characters in your scene you must clone the loaded scene graph for each one. Do not use scene.clone(true) — it copies nodes but leaves cloned SkinnedMesh instances still bound to the original skeleton's bones, so all copies deform together or render invisible.
Use THREE.SkeletonUtils.clone() (Three.js) or your engine's equivalent deep-clone that rebinds skeleton bones per clone:
import { clone } from 'three/examples/jsm/utils/SkeletonUtils.js';
// gltf = the result of GLTFLoader.load(assets.skeleton_glb, ...)
for (const C of characters) {
const root = clone(gltf.scene); // correct — rebinds bones per clone
// hide all meshes, then show C.body_id, C.face_id, C.hair_id + shared_parts
root.position.set(C.x, 0, C.z);
scene.add(root);
}
Layer 2 — Runtime (after loading)
The API provides the starting state. At runtime, your Unity / Tuanjie code drives the simulation. The recipe fields are configuration inputs for your own agent system:
| Field | What it controls |
|---|---|
boundaryShape + boundaryRadius |
Keep agents inside this area |
pace |
Fraction walking vs. idle |
walkSpeedMin / walkSpeedMax |
Per-agent speed range |
collisionRadius |
Minimum distance between agents |
personalSpace |
How hard agents push back when crowded |
anticipation |
How far ahead agents start steering |
obstacles[] |
Static objects to navigate around |
attractTarget |
A point agents drift toward |
Your engine has full runtime control — you can override any of these values per frame, move the attractor, add obstacles dynamically, or change the pace on the fly. The API provides the authored starting values; it does not drive the simulation at runtime.
Planned future features
- Runtime API — a live connection so a director can update the crowd mid-scene without reloading.
- Live editor link — Genji editor pushes recipe changes directly to a running Unity / Tuanjie instance.
- Per-character overrides — swap one character's costume or position without reloading the full crowd.
Current model: load once, simulate yourself.