{"openapi":"3.0.3","info":{"title":"Genji Crowd API","version":"1.0.0","description":"Procedural historical Chinese crowd generator API. A crowd_id identifies a saved crowd configuration (recipe). GET /v1/crowd/{crowd_id} returns the full recipe, pre-resolved per-character part assignments (body/face/hair SkinnedMesh node IDs), world positions, animation state, and GLB asset download URLs.\n\nAll parts live inside a single skeleton_glb file as named SkinnedMesh nodes. Coordinate system: right-handed, Y-up. Character positions (x, z) are in METRES. The GLB scene is also in METRES as loaded — the cm→m conversion (0.01) is baked into the GLB root node. Do NOT apply any extra unit scale; a character should stand ~1.7 m tall straight out of the loader. rot_y=0 → faces +Z. For Unity (left-handed): negate z and rot_y.\n\nSanity check: after loading, measure the bounding box height of a visible character. ~1.7 = correct; ~170 = your importer stripped the root scale (apply 0.01 yourself); ~0.017 = you applied 0.01 on top of the baked scale (remove it).\n\nSample key: YOUR_API_KEY  |  Sample crowd: cr_sample01"},"servers":[{"url":"https://www.genjicrowd.com","description":"Current server"}],"security":[{"bearerAuth":[]}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"API key in format gk_live_… — obtain from the Genji dashboard. Sample for testing: YOUR_API_KEY"}},"schemas":{"Error":{"type":"object","properties":{"error":{"type":"string","example":"Crowd not found"}}},"Obstacle":{"type":"object","properties":{"type":{"type":"string","example":"rock"},"x":{"type":"number","description":"World X position (metres)"},"z":{"type":"number","description":"World Z position (metres)"},"radius":{"type":"number","description":"Avoidance cylinder radius (metres)"}}},"Recipe":{"type":"object","description":"Complete crowd configuration. Feed these values into your agent simulation engine.","properties":{"v":{"type":"integer","example":1,"description":"Recipe format version."},"seed":{"type":"integer","example":42,"description":"Mulberry32 RNG seed. Deterministically produces the characters[] list."},"size":{"type":"integer","example":150,"description":"Total character count."},"weights":{"type":"object","additionalProperties":{"type":"number"},"example":{"soldiers":35,"laborers":40},"description":"Bucket weight map — {bucketId: weight}. Bucket IDs match manifest.json → buckets[].id."},"walkSpeedMin":{"type":"number","example":0.6,"description":"Min walk speed multiplier relative to clip speed."},"walkSpeedMax":{"type":"number","example":1,"description":"Max walk speed multiplier."},"pace":{"type":"number","minimum":0,"maximum":1,"example":0.5,"description":"Fraction of characters that walk vs. idle. 0=all idle, 1=all walking."},"scaleVariation":{"type":"number","example":0.06,"description":"±scale jitter (uniform scale = 1 + (basis−0.5)×2×scaleVariation)."},"boundaryShape":{"type":"string","enum":["circle","square","road","donut","spline"],"description":"Spawn boundary shape."},"boundaryRadius":{"type":"number","example":80,"description":"Outer boundary radius/half-length (metres)."},"spawnRadius":{"type":"number","example":80,"description":"Inner spawn radius/half-width (metres)."},"splinePoints":{"type":"array","nullable":true,"items":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":2},"description":"[[x,z]…] control points for spline shape; null otherwise."},"facing":{"type":"number","minimum":0,"maximum":1,"description":"0=random facing, 1=all face the same direction."},"distribution":{"type":"string","enum":["random","grid","startpoint"]},"terrain":{"type":"object","properties":{"active":{"type":"boolean"},"amplitude":{"type":"number","description":"Terrain height (metres)."},"frequency":{"type":"number"},"noiseType":{"type":"string","enum":["fractal","simplex"]}}},"hueShift":{"type":"number","description":"Global garment hue rotation."},"hueVar":{"type":"number","description":"Per-character hue jitter range."},"satVar":{"type":"number","description":"Per-character saturation jitter range."},"lightVar":{"type":"number","description":"Per-character lightness jitter range."},"collisionRadius":{"type":"number","description":"NPC body radius multiplier × character height. Two touching NPCs are collisionRadius×height apart."},"personalSpace":{"type":"number","description":"Centering/path-following force (0–1)."},"anticipation":{"type":"number","minimum":0,"maximum":1,"description":"0=react on contact, 1=steer early."},"obstacles":{"type":"array","items":{"$ref":"#/components/schemas/Obstacle"}},"groupingMode":{"type":"boolean","description":"Characters cluster into groups."},"attractActive":{"type":"boolean"},"attractTarget":{"type":"object","properties":{"x":{"type":"number"},"z":{"type":"number"}}},"collection":{"type":"string","example":"genji-v1"}}},"Character":{"type":"object","description":"One NPC with pre-resolved part IDs and spawn state. Derived deterministically from recipe.seed via Mulberry32 RNG.","properties":{"index":{"type":"integer","description":"0-based character index."},"bucket":{"type":"string","description":"Archetype bucket ID (matches manifest.json → buckets[].id)."},"body_id":{"type":"string","description":"SkinnedMesh node name inside skeleton_glb to use as the body/outfit."},"face_id":{"type":"string","description":"SkinnedMesh node name for the face."},"hair_id":{"type":"string","description":"SkinnedMesh node name for headwear/hair."},"x":{"type":"number","description":"World X (metres). Place character at (x, terrain_y, z)."},"z":{"type":"number","description":"World Z (metres)."},"rot_y":{"type":"number","description":"Y-axis rotation (radians). 0=faces +Z. Right-handed Y-up. Unity: negate."},"scale":{"type":"number","description":"Per-character SIZE VARIATION multiplier (typically 0.95–1.05). Apply as the clone's uniform scale. It is NOT a unit conversion — the GLB is already in metres."},"phase":{"type":"number","minimum":0,"maximum":1,"description":"Animation phase offset. startTime = phase × clipDuration. Prevents crowd lockstep."},"anim_clip":{"type":"string","enum":["idle","walk"],"description":"Clip to play at spawn."},"idle_variant":{"type":"integer","minimum":0,"maximum":7,"description":"Index into assets.animations idle variants (0=idle, 1=idle_arms_crossed, … 7=idle_tired). Only used when anim_clip=\"idle\"."},"garment_hsl":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3,"description":"[H, S, L] normalised 0–1. Complete garment colour — apply directly with setHSL(h, s, l) on the body mesh material. This is NOT a shift on top of manifest bucket tints; it is the full colour, pre-computed from recipe hueShift + per-character jitter."},"skin_hex":{"type":"string","description":"Hex colour for face + shared skin meshes (eyes, hands, feet)."},"hair_hex":{"type":"string","description":"Hex colour for hair/headwear mesh."}}},"AnimationMap":{"type":"object","description":"All animation clip GLB URLs. Clips are authored for the rest_reference_glb skeleton; retarget them to skeleton_glb using the bone quaternion delta technique (see API.md).","properties":{"idle":{"type":"string","format":"uri"},"idle_arms_crossed":{"type":"string","format":"uri"},"idle_hands_hip":{"type":"string","format":"uri"},"idle_clasp":{"type":"string","format":"uri"},"idle_shift":{"type":"string","format":"uri"},"idle_look_around":{"type":"string","format":"uri"},"idle_scratch":{"type":"string","format":"uri"},"idle_tired":{"type":"string","format":"uri"},"walk":{"type":"string","format":"uri"},"jog":{"type":"string","format":"uri"},"walk_stop":{"type":"string","format":"uri"}}},"Assets":{"type":"object","description":"Download URLs for all GLB assets. URLs reflect the server's ASSET_BASE_URL — do NOT hardcode localhost. Two rendering paths are supported: per-character cloning (skeleton_glb + SkeletonUtils.clone, simple, fine up to a few hundred characters) and GPU-instanced rendering (bone_tex_bin/bone_tex_manifest, for 1000+ characters — see the \"GPU-instanced rendering\" doc section).","properties":{"unit":{"type":"string","enum":["m"],"description":"Effective GLB scene unit as loaded: metres. The source skeleton is Maya cm, but the export bakes a 0.01 scale into the GLB root node, so no conversion is needed."},"unit_scale":{"type":"number","example":1,"description":"Extra scale to apply to the GLB root. Always 1.0 — the cm→m conversion is already baked into the file. Applying 0.01 yourself will make characters 100x too small. Sanity check: a loaded character should be ~1.7 m tall."},"skeleton_glb":{"type":"string","format":"uri","description":"Master character GLB. Contains ALL body/face/hair meshes as named SkinnedMesh nodes on one shared skeleton."},"lod_glb":{"type":"string","format":"uri","description":"Lower-poly LOD variant — same node names as skeleton_glb."},"rest_reference_glb":{"type":"string","format":"uri","description":"Basemesh GLB used as animation retarget reference. Animation clips in this API are authored for this skeleton's rest pose."},"manifest_json":{"type":"string","format":"uri","description":"JSON catalogue listing all parts, buckets, and shared nodes."},"bone_tex_bin":{"type":"string","format":"uri","description":"Raw Float32Array binary (RGBA32F layout) of a baked bone-matrix texture for GPU-instanced crowd rendering — an alternative to per-character cloning for crowds of 1000+. Pair with bone_tex_manifest to interpret it. Reference implementation: /sdk/genji-crowd-instanced.js."},"bone_tex_manifest":{"type":"string","format":"uri","description":"Describes bone_tex_bin: texWidth, texHeight, nBones, bakeFps, per-clip frame ranges (clipInfo), and the clip index to use for anim_clip=\"walk\". Required to interpret the binary."},"animations":{"$ref":"#/components/schemas/AnimationMap"}}},"CrowdResponse":{"type":"object","properties":{"crowd_id":{"type":"string","example":"cr_sample01"},"name":{"type":"string","example":"Imperial Crowd — Tuanjie Sample"},"collection":{"type":"string","example":"genji-v1"},"recipe":{"$ref":"#/components/schemas/Recipe"},"characters":{"type":"array","items":{"$ref":"#/components/schemas/Character"},"description":"One entry per NPC, size = recipe.size. Deterministic from recipe.seed."},"shared_parts":{"type":"array","items":{"type":"string"},"description":"SkinnedMesh node names rendered on every character (eyes, hands, feet). Constant — same for all characters.","example":["Eye_L","Eye_R","L_Hand","R_Hand","L_Foot","R_Foot"]},"assets":{"$ref":"#/components/schemas/Assets"},"updated_at":{"type":"string","format":"date-time"}}}}},"paths":{"/v1/crowd/{crowd_id}":{"get":{"summary":"Get crowd recipe and character list","operationId":"getCrowd","description":"Returns the full crowd recipe, all pre-resolved character part assignments, and GLB asset URLs.\n\nRendering guide — the GLB scene is ALREADY IN METRES (a 0.01 cm→m scale is baked into its root node). Do NOT apply any unit conversion anywhere:\n1. Download assets.skeleton_glb once and load it — this is your ORIGINAL, un-cloned scene.\n2. Sanity check the load: a character should stand ~1.7 m tall. If ~170, your importer stripped\n   the root-node scale — re-apply 0.01 at the root. If ~0.017, you added a 0.01 that should not be there.\n3. For each character: clone the scene (SkeletonUtils.clone or equivalent).\n4. Set visible=true for body_id, face_id, hair_id, and each of shared_parts; hide all other SkinnedMesh nodes.\n5. Place at world (x, 0, z) — no Y offset needed, the root is already at the character's feet —\n   apply Y rotation rot_y, then set the clone's uniform scale to character.scale\n   (the small ~0.95–1.05 per-character size variation).\n6. Load animation clip from assets.animations[anim_clip] (or idle_variants[idle_variant]).\n   Animation clips are authored for rest_reference_glb — retarget to skeleton_glb before playing.\n7. Start clip playback at time = phase × clipDuration.","parameters":[{"name":"crowd_id","in":"path","required":true,"schema":{"type":"string","pattern":"^cr_[a-zA-Z0-9]{8}$","example":"cr_sample01"},"description":"Crowd identifier. Use cr_sample01 with the sample key for testing."}],"responses":{"200":{"description":"Crowd recipe, character list, and asset URLs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CrowdResponse"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"crowd_id not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}}}