快速开始 — 只需两步:
1. API 密钥:YOUR_API_KEY
2. curl -H "Authorization: Bearer YOUR_API_KEY" https://www.genjicrowd.com/v1/crowd/cr_sample01
Genji 人群 API — v1
合作伙伴集成参考文档(适用于 Tuanjie / Unity 原生渲染)。
版本说明
本文档描述 API v1,版本号体现在 URL 前缀中(/v1/)。
破坏性变更(字段移除、重命名或语义变更)将使版本升级至 /v2/,
届时 v1 将提前公告废弃,不会立即下线。
非破坏性变更(新增可选响应字段)不会更改版本号。
概述
Genji 人群 API 允许引擎加载已保存的人群配方(recipe)——即在 Genji 编辑器中创作的人群所需的完整配置——以及预先解析好的每个角色的部件分配和直接资源 URL。
典型集成流程:
GET /v1/crowd/{crowd_id}
→ recipe(人群配置)
→ characters[](体型 / 面部 / 发型 ID,位置,动画状态)
→ assets(GLB URL,动画 GLB URL)
引擎随后:
- 下载一次
assets.skeleton_glb(包含共享骨架上的所有部件网格)。 - 对
characters[]中的每个条目,选择对应网格节点并应用每个角色的颜色、缩放和动画相位。 - 使用配方中的边界 / 碰撞参数运行自己的智能体模拟。
资源托管
所有 GLB 和动画文件由响应请求的服务器提供。 资源 URL 已完整解析并包含在每次 API 响应中——您无需自行构建。
| 环境 | 资源来源 |
|---|---|
| 本地开发 | 服务器自身主机(如 http://localhost:3001) |
| ngrok 隧道 | 隧道 URL(如 https://genji-api.ngrok.app) |
| 生产环境 | 由部署团队配置(Cloudflare R2 / CDN) |
请勿硬编码资源 URL。 始终从
response.assets.*中读取。 服务器在请求时构建完整解析的 URL,反映当前运行位置。 将本文档中的示例 URL 复制到代码中将指向错误的主机,请直接使用assets.skeleton_glb、assets.animations.walk等字段。
鉴权
每个请求必须在 Authorization 头中携带 Bearer 令牌:
Authorization: Bearer gk_live_…
密钥通过 Genji 控制台(设置 → API 密钥)或由部署团队签发。 原始密钥仅在创建时显示一次,不以明文形式存储。
错误响应:
| 状态码 | 响应体 | 原因 |
|---|---|---|
401 |
{"error":"Missing Authorization: Bearer <api_key>"} |
缺少请求头 |
401 |
{"error":"Invalid or revoked api key"} |
密钥错误或已吊销 |
404 |
{"error":"Crowd not found"} |
crowd_id 不存在 |
端点
`GET /v1/crowd/{crowd_id}`
返回一个已保存人群的完整配方、预解析角色列表和资源 URL。
路径参数
| 参数 | 类型 | 说明 |
|---|---|---|
crowd_id |
string |
不透明 ID,格式为 cr_[a-zA-Z0-9]{8} |
响应 `200 OK` — `application/json`
{
"crowd_id": "cr_sample01", // string — 与请求路径匹配
"name": "Imperial Crowd — Tuanjie Sample",
"collection": "genji-v1", // string — 集合标签(可为 "")
"recipe": { ... }, // object — 见下方配方结构
"characters": [ ... ], // array — 见下方角色对象
"shared_parts": ["Eye_L","Eye_R","L_Hand","R_Hand","L_Foot","R_Foot"],
"assets": { ... }, // object — 见下方资源 URL
"updated_at": "2026-06-24 10:00:00" // string — ISO 风格 UTC 时间戳
}
配方结构(Recipe Schema)
recipe 是一个包含以下字段的 JSON 对象。
核心字段
| 字段 | 类型 | 说明 |
|---|---|---|
v |
integer |
配方版本,当前为 1。 |
seed |
integer |
主 RNG 种子,决定所有角色分配和初始位置(确定性)。 |
size |
integer |
人群中的角色总数。 |
weights |
object |
桶权重映射 — { bucketId: number },控制服装原型的混合比例。桶 ID 见 能力目录(Capabilities)。 |
行走 / 动画
| 字段 | 类型 | 说明 |
|---|---|---|
walkSpeedMin |
float |
最小行走速度倍数(相对于动画片段速度),默认 0.6。 |
walkSpeedMax |
float |
最大行走速度倍数,默认 1.0。 |
walkRatio |
float |
旧版字段(已被 pace 取代)。 |
pace |
float 0–1 |
行走角色占总数的比例。0 = 全部待机,1 = 全部行走,默认 0.5。 |
crowdRate |
float |
流动 / 道路模式下每分钟进入的角色数。 |
布局
| 字段 | 类型 | 说明 |
|---|---|---|
boundaryShape |
string |
人群边界形状:"circle"、"square"、"road"、"donut"、"spline"。 |
boundaryRadius |
float |
外边界半径(米)。road 模式下为半长。 |
spawnRadius |
float |
内部生成半径(米)。road 模式下为半宽。 |
splinePoints |
[[x,z], …] | null |
spline 形状的控制点(世界空间 XZ)。其他形状为 null。 |
facing |
float 0–1 |
0 = 随机朝向,1 = 全部朝同一方向。 |
distribution |
string |
"random" 或 "grid",控制边界内位置的分布方式。 |
地形
| 字段 | 类型 | 说明 |
|---|---|---|
terrain.active |
boolean |
true = 启用程序化地形。 |
terrain.amplitude |
float |
地形高度振幅(米)。 |
terrain.frequency |
float |
地形噪声频率。 |
terrain.noiseType |
string |
"fractal" 或 "simplex"。 |
颜色变化
所有服装颜色均为桶特定的 HSL 值,并为每个角色随机抖动。
| 字段 | 类型 | 说明 |
|---|---|---|
hueShift |
float |
应用于所有角色服装的全局色相旋转。 |
hueVar |
float |
每角色色相抖动范围 ±hueVar。 |
satVar |
float |
每角色饱和度抖动范围 ±satVar。 |
lightVar |
float |
每角色亮度抖动范围 ±lightVar。 |
碰撞 / 避让
这些参数输入到您自己的智能体模拟引擎中。
| 字段 | 类型 | 说明 |
|---|---|---|
collisionRadius |
float |
NPC 体积半径倍数(× 角色高度)。两个角色距离 = 2 × 半径 × 高度时视为"接触"。 |
personalSpace |
float |
向心力 / 路径跟随力强度(0–1)。 |
anticipation |
float 0–1 |
NPC 开始躲避碰撞的提前程度。0 = 接触时才反应,1 = 提前转向。 |
障碍物
"obstacles": [
{ "type": "rock", "x": 20.0, "z": 15.0, "radius": 3.5 },
...
]
每个障碍物为世界空间 XZ 中的圆柱(Y 轴无限延伸)。角色会绕开以 (x, z) 为圆心、半径为 radius 米的圆盘区域。
效应器
| 字段 | 类型 | 说明 |
|---|---|---|
groupingMode |
boolean |
为 true 时,角色聚集成群组。 |
attractActive |
boolean |
全局吸引器是否激活。 |
attractTarget |
{x, z} |
吸引器的世界空间 XZ 位置。 |
渲染标志
这些是创作工具中的视口开关,供渲染器参考使用。
| 字段 | 类型 | 说明 |
|---|---|---|
textures |
boolean |
保存时作者已开启纹理。 |
collisionEnabled |
boolean |
NPC 间避让已启用。 |
jogBlendEnabled |
boolean |
行走→慢跑混合已启用。 |
lazyGPU |
boolean |
内部 GPU 更新优化标志。 |
collection |
string |
集合标签(如 "genji-v1")。 |
角色对象(Character Object)
characters[] 中的每个条目为一个已预解析部件 ID 的 NPC。
{
"index": 0, // integer — 0 起的角色索引
"bucket": "soldiers", // string — 原型桶 ID
"body_id": "M_Terracotta_Infantry_Soldier",// string — skeleton_glb 中的 SkinnedMesh 节点名
"face_id": "M_Chinese_Face_03", // string — skeleton_glb 中的 SkinnedMesh 节点名
"hair_id": "Ming_Warrior_Crown", // string — skeleton_glb 中的 SkinnedMesh 节点名
"x": 12.3400, // float — 初始 X 坐标(米,世界空间)
"z": -5.7100, // float — 初始 Z 坐标(米,世界空间)
"rot_y": 1.2340, // float — Y 轴初始旋转(弧度)
"scale": 1.0230, // float — 统一缩放倍数
"phase": 0.6710, // float 0–1 — 动画相位偏移(避免集体同步)
"anim_clip": "walk", // string — 生成时的动画:"idle" 或 "walk"
"idle_variant": 2, // integer 0–7 — 待机动画变体编号
"garment_hsl": [0.0521, 0.5182, 0.4830], // [H, S, L] 归一化 0–1 — 每角色服装色调
"skin_hex": "#ddb892", // string — 皮肤颜色十六进制
"hair_hex": "#1a1410" // string — 发色十六进制
}
共享部件
除 body_id、face_id 和 hair_id 外,每个角色还渲染六个共享网格节点(眼睛、手、脚)。
这些对所有角色相同,列于顶层 shared_parts 数组中:
["Eye_L", "Eye_R", "L_Hand", "R_Hand", "L_Foot", "R_Foot"]
坐标系
API 响应中的所有位置、距离和旋转均使用以下约定:
| 属性 | 值 |
|---|---|
| 手性 | 右手坐标系 |
| 向上轴 | Y |
| 单位 | 米 |
| 地面平面 | XZ(Y = 地形高度,平地上为 0) |
rot_y = 0 |
角色朝向 +Z |
正 rot_y |
从上方(+Y)俯视逆时针旋转 |
角色位置以 (x, z) 对给出(Y 始终为地面高度)。
将角色放置于世界坐标 (x, groundHeight, z),并应用 Y 轴旋转 rot_y 弧度。
角色朝向方向向量:
forward_x = sin(rot_y)
forward_z = cos(rot_y)
与 GLB 资源一致:skeleton_glb 和所有动画 GLB 均以 glTF 约定导出(右手系,Y 向上,米)。加载时无需进行轴交换。
开箱即用即为米制。 API 响应中的
x/z(角色位置)以米为单位,GLB 场景加载后同样以米为单位: 源骨架以 Maya 厘米制作,但导出时已将 0.01 缩放烘焙进 GLB 根节点。 请勿再做任何单位换算 —— 加载后的角色身高约 1.7 米。 加载后自检:约 1.7 = 正确;约 170 = 你的导入器丢弃了根节点缩放(需自行在根节点应用 0.01); 约 0.017 = 你额外应用了一次 0.01(必须移除)。
Unity / 左手引擎: Unity 使用左手 Y 向上坐标系。 转换方式:取反
z→position.z = -C.z,取反rot_y→rotation.y = -C.rot_y。 转换方向向量时同样取反forward_z。
rot_y是出生时刻的朝向快照,不是运动方向向量。 它根据配方的出生区域形状 一次性生成(例如圆形边界时基本是随机值)。如果你自行实现了角色运动逻辑 (自定义路径、让角色沿街道行走、转向/避让等)——请每帧根据自己的速度矢量 重新计算朝向,不要对自行移动的角色继续使用 API 提供的静态rot_y:rot_y = atan2(velocity.x, velocity.z)。仅当角色为idle(始终静止)或你在 原样复现配方描述的出生布局、未额外添加自定义运动时,才可直接使用 API 返回的rot_y。
资源 URL
// ⚠️ 这些 URL 由服务器返回——不由客户端构建。
// 主机名随环境变化(本地开发 / ngrok / 生产 CDN)。
// 始终使用响应中的值,切勿硬编码基础 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"
}
}
部件 ID 与资源的映射关系
所有角色部件均存于单一 GLB 文件中——skeleton_glb。
每个部件 ID(如 M_Terracotta_Infantry_Soldier)是该文件中 SkinnedMesh 节点的名称。渲染角色的步骤:
- 加载一次
skeleton_glb,其中包含一个共享骨架及每个部件对应的SkinnedMesh节点。 - 对
characters[]中的每个角色,将以下网格节点设为可见:body_id节点face_id节点hair_id节点- 全部 6 个
shared_parts节点(眼睛、手、脚) - 所有其他网格节点:隐藏
- 实例化或克隆可见集,应用角色的
scale、rot_y和(x, z)位置。 - 应用每角色颜色(见下方颜色应用)。
- 播放
anim_clip指定的动画片段,偏移量为phase × clip_duration。
lod_glb 是一个独立的第二份 GLB 文件——节点名称、共享骨架、缩放/原点行为均与
skeleton_glb 相同,仅使用更低面数的网格(文件大小约为原版的 40%)。它不是
skeleton_glb 内部的网格变体,需要单独加载并以相同方式克隆。对于此规模的人群,
一个合理的默认策略是:距相机约 15–20 米内使用高精度 skeleton_glb,超出此距离
切换为 lod_glb。
每个角色任意时刻只需保留一份激活的克隆,不需要同时维护两份。 切换成本很低:
当角色越过 LOD 距离阈值时,替换其克隆体(高精度 ↔ LOD),但将当前动画片段和
播放时间带到新克隆的 mixer 上(newAction.time = oldAction.time),而不是从
0 重新开始。"不要在动画播放过程中热切换" 说的正是这一点——切换时保留播放进度。
这并不意味着要让两种精度的角色同时并行运行;那样会让每个角色始终占用双倍
内存和双倍动画计算,而实际上任意时刻只有一部分角色需要做距离判断。
capabilities_json(GET /v1/capabilities)列出可用的形状、可加权的桶 id/标签,以及每个部件的 id、标签、组别和性别,适合用于构建 UI 或验证工具。
颜色应用
每个角色有三个颜色输入:
| 输入 | 来源 | 应用目标 |
|---|---|---|
garment_hsl |
每角色 HSL(0–1 归一化) | 身体网格 —— 直接设置:material.color.setHSL(h, s, l) |
skin_hex |
十六进制字符串 | 面部 + 共享皮肤网格 |
hair_hex |
十六进制字符串 | 发型网格 |
garment_hsl 是完整的最终服装颜色,由服务端计算。请直接用 material.color.setHSL(h, s, l) 应用 —— 切勿从其他来源推导或叠加。
另外请先为每个角色克隆材质再着色,否则所有角色会共享同一颜色。
GPU 实例化渲染(骨骼纹理)
上文描述的默认渲染路径(SkeletonUtils.clone() + 每角色一个 AnimationMixer)实现
简单,但扩展性有限 —— 根据硬件不同,大约在 150–300 个角色规模就会跌出流畅帧率
(实测数据:150–5000 个独立克隆角色时帧率为 17–24 fps)。
对于 1000+ 角色的人群,请改用 assets.bone_tex_bin + assets.bone_tex_manifest:
一份共享的 GPU 纹理,编码了每个动画片段的骨骼矩阵,在自定义蒙皮着色器中逐顶点采样,
通过每个身体/面部/发型部件一个 THREE.InstancedMesh(而非每角色一个)渲染 ——
无论人群规模多大,整个人群通常只需 ≤36 次绘制调用。这与 Genji 人群创作工具自身
用于以流畅帧率渲染数千角色的技术完全相同。
"assets": {
"bone_tex_bin": "{base}/assets/bone_tex.bin",
"bone_tex_manifest": "{base}/assets/bone_tex_manifest.json"
}
可直接使用的参考实现位于 /sdk/genji-crowd-instanced.js(独立 ES 模块,唯一的
对等依赖是 three),并附带一个可运行示例 /sdk/genji-crowd-instanced-demo.html
(追加 ?size=2000 可控制人群规模)。
工作原理
bone_tex_bin 是一份原始 Float32Array 二进制数据(无文件头、无压缩),按 RGBA32F
纹理布局排列:width = bone_tex_manifest.texWidth,height = bone_tex_manifest.texHeight。
每个动画片段占据一段连续的纹理列(每帧一列,30 fps 烘焙);每根骨骼占据 4 行纹理
(一个 mat4,存为 4 个 vec4 行)。你的着色器逐顶点采样该纹理,按 skinWeight
混合 4 根影响骨骼,重建出与 SkeletonUtils.clone() 在 CPU 上计算结果完全一致的蒙皮
位置 —— 只是这次是在 GPU 上为一个部件的所有实例统一计算一次,而不是每个克隆角色
在 CPU 上各算一次。
使用时无需理解这些细节 —— 参考 SDK 中的 loadBoneTexture() 会为你构建
THREE.DataTexture,内置着色器已完成采样逻辑。
bone_tex_manifest 字段
| 字段 | 类型 | 含义 |
|---|---|---|
texWidth / texHeight |
int | bone_tex_bin 的 RGBA32F 纹理尺寸。 |
nBones |
int | 骨骼数量(与 skeleton_glb 的骨架一致)。 |
bakeFps |
int | 动画烘焙时使用的帧率(30)。 |
format |
string | 固定为 "RGBA32F" —— 每纹素 4 个浮点数,无压缩。 |
totalBakedFrames |
int | 所有已烘焙片段的总帧数。 |
numIdle |
int | 待机变体数量(索引 0..numIdle-1,与 idle_variant 对应)。 |
clipWalk |
int | anim_clip === "walk" 时应使用的片段索引。 |
clipInfo |
object | 按片段名称索引的 {start, frames, duration}。 |
modelScale |
number | 仅供参考 —— 已烘焙进纹理数据,请勿重复应用。 |
最简集成示例
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);
}
适用范围:不支持每角色速度差异
该渲染路径严格实现公开 Character 数据结构中已有的字段
(anim_clip/idle_variant/phase/garment_hsl/skin_hex/hair_hex/x/z/rot_y/scale)
—— API 中没有速度或动量字段,因此 walk 始终以其原始烘焙速度播放。角色之间仅通过
phase(相位错开)产生差异,而非播放节奏。这是人群规模渲染下已知且可接受的取舍,
并非缺陷:2000 名步行者组成的人墙,观感会比手工调校、带每角色速度差异的模拟略显
整齐划一。
常见集成故障(骨骼纹理路径)
| 症状 | 原因 | 解决方法 |
|---|---|---|
| 所有角色共享同一姿势 / T-pose | 在 loadBoneTexture() 完成前就开始渲染 |
调用 buildCrowdInstances 前先 await 它 |
| 角色完全不动画(静止不动) | uTime uniform 从未更新 |
每帧调用 updateCrowdTime |
| 所有角色播放同一片段/姿势 | 未正确写入每实例的 aClip/aPhase |
使用 writeCrowdInstances,不要手写实例缓冲区 |
| 请求了 jog/run 动画但不播放 | 不可用 —— 公开 API 仅提供 idle/walk | 属预期行为 —— jog 仅供内部使用,不在此 API 范围内 |
| 绘制调用数超过约 36 次 | 按每角色而非每部件构建了 InstancedMesh | 应按每个唯一的 body_id/face_id/hair_id/共享部件值各建一个,而非每角色一个 |
超级优化渲染档位(无纹理 / VAT / BBM)
上述骨骼纹理路径已能以约 36 次绘制调用满帧渲染整个人群。对于超大规模(数万角色) 或显存 / GPU 受限的目标平台(移动端、VR、集成显卡),还有三个档位可以用视觉精度 换取纯吞吐量。这些正是 Genji 工具内部使用的同一批优化。
这三者消耗的都正是 API 已返回的数据 —— assets.skeleton_glb 以及
assets.bone_tex_bin + assets.bone_tex_manifest。没有额外接口,也没有额外下载;
每个档位都是你在客户端做出的渲染选择。它们还可以作为 LOD 分带混用(近处用完整网格,
远处用 BBM),而且由于每个档位读取的都是同一份骨骼纹理,角色在切换档位时动画始终保持同步。
一览
| 档位 | 绘制调用 | 逐顶点计算 | 额外显存 | 适用场景 |
|---|---|---|---|---|
| 完整蒙皮(骨骼纹理) | 约 36 | 4 骨骼采样 + 加权混合 | 部件纹理 | 默认;近处相机 |
| 无纹理 | 约 36 | 4 骨骼混合,无贴图采样 | 无(释放漫反射贴图) | 显存受限;风格化;远处分带 |
| VAT | 约 36 | 2–8 次采样,无混合 | 大(整套约 2.4 GB) | ALU / 蒙皮混合受限 |
| BBM 骨骼立方体代理 | 1 | 1 次采样 + 1 次矩阵乘法 | 无 | 超大规模;远处 LOD |
1. 无纹理(扁平)模式
完全不加载部件的漫反射 / 法线 / 粗糙度贴图,将每个部件渲染为一个受光的纯色(白色, 或每部件一个纯色调)。这会释放全部纹理显存,并从片段着色器中移除每一次纹理采样 —— 是这里最轻量的改动,也很适合远处 LOD 分带或内存有限的设备。
- 不绑定任何
map/normalMap/roughnessMap;给材质一个纯色。 - 若要在 PBR 材质上得到干净的白色外观,添加一个较小的白色自发光(约 0.35), 让中间调从灰色中提亮;扁平 / 无光照材质则不需要。
- 该模式与 VAT 和 BBM 正交 —— 任何档位下都可以无纹理渲染。
recipe.textures标志告诉你 作者保存时是否开启了纹理。
2. VAT —— 顶点动画纹理(Vertex Animation Texture)
将蒙皮后的顶点位置(即骨骼纹理着色器计算出的完全相同的结果)预烘焙到一张按
[绝对帧, 顶点ID] 索引的纹理中。运行时顶点着色器只做一次查表并在两帧之间插值,而不再
混合四根骨骼矩阵:
// f0,f1 = 夹住当前时间的两个帧列;fr = 混合系数
vec3 pos = mix( texelFetch(uVAT, ivec2(f0, gl_VertexID), 0).xyz,
texelFetch(uVAT, ivec2(f1, gl_VertexID), 0).xyz, fr );
- 原因: 将逐顶点蒙皮从约 32–64 次纹理采样压缩到 2(待机)、4(行走 / 慢跑) 或 8(交叉淡入淡出中)。当瓶颈是 4 骨骼混合本身(而非绘制调用或内存)时最有效。
- 代价: 显存。每个顶点 × 每个烘焙帧都以一个
vec3存储;整套约 2.4 GB。它是 在加载时于客户端一次性烘焙的,数据源正是 API 提供的bone_tex_bin—— 故意不 预先烘焙好再传输(数据量太大)。 - 烘焙: 对每个部件几何体、对
bone_tex_manifest.clipInfo中的每个烘焙帧,在 GPU 上 运行一次骨骼纹理蒙皮,并将每个顶点的世界位置写入VAT[帧][顶点ID]。复用bone_tex的帧布局,使片段索引 /phase保持一致。 - 范围: 与 BBM 互斥(VAT 保留真实网格;BBM 则替换掉它)。
3. BBM —— 骨骼立方体代理(超大规模 LOD)
用约 17 个刚性基本体替换每个角色的蒙皮网格 —— 每根骨骼一个立方体 / 圆柱体(四肢圆柱、 两个躯干块、一个头部、双手),每个都固定到单根骨骼上。没有 4 骨骼混合:每顶点只做一次 骨骼矩阵采样。整个人群合并为一个实例化网格 → 1 次绘制调用,是所有档位中最廉价的动画 顶点着色器。
从骨架绑定姿势(skeleton_glb)一次性构建它: 对每根四肢骨骼,构建一个从该骨骼延伸到
其子骨骼的圆柱;躯干和头部则根据髋部 / 肩部 / 头部骨骼的位置拟合。在每个基本体所属骨骼的
局部绑定空间中构建它,然后给它的每个顶点打上那一根骨骼的索引(aBone)。
运行时: 从同一份骨骼纹理采样那一根骨骼的动画矩阵,并刚性地变换顶点 —— 这是父级 约束,而非蒙皮:
mat4 b = boneMatrix(aBone); // 从 bone_tex(共享纹理)采样
vec3 world = (b * vec4(pos, 1.0)).xyz * UNIT_SCALE; // 见下方陷阱
关键的顺序陷阱(踩坑换来的经验)。 骨骼纹理把厘米→米的
0.01模型缩放烘焙进了 每个矩阵(bone_tex_manifest.modelScale),而立方体几何体以米为单位,因此你要用一个×100来补偿。把×100施加在变换后的点上,而不是输入上:(b * p) * 100, 绝不能写成b * (p * 100)。均匀缩放与矩阵的旋转部分可交换,但与其平移部分 不可交换 —— 放错一边,旋转看起来仍然正确,但每根骨骼的姿势偏移被压缩到 1%,于是 任何远离绑定姿势的骨骼(迈步中的小腿、摆动的手臂)的立方体都会脱离身体并漂浮。在 绑定姿势的 T-pose 下一切正常,只有动起来才会崩,因此这个 bug 极易被漏掉又难以定位。
- 颜色: 立方体没有纹理;用角色的
garment_hsl(身体 / 四肢)、skin_hex(头部 / 双手) 和hair_hex给每个基本体上色。无纹理模式下则渲染为扁平白色,以匹配完整网格。 - 适用: 约 50 米以外,或数万角色的人群中只有轮廓和动作能被看清之处。将其作为完整网格 (或 VAT)近处分带之下的远处 LOD 分带使用。
动画片段
共有 8 个待机变体(索引 0–7,anim_clip = "idle")和 1 个行走片段(anim_clip = "walk")。使用 idle_variant 选择对应待机 GLB。
anim_clip |
idle_variant |
GLB 键 |
|---|---|---|
"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" |
(任意) | animations.walk |
所有片段共享 skeleton_glb 中的同一骨架,加载并重定向一次即可。
animations.walk 在根骨骼上烘焙了根运动位移,方向严格沿局部 +Z 轴(已验证:
整个片段中根节点位移从 [0,0,0] 变化到 [0,0,1.7])——与 rot_y = 0 时角色的
朝向完全一致。如果角色看起来在倒着走,问题不在动画片段本身——请检查你自己的
位置/旋转代码(很可能是 atan2 参数顺序颠倒,或从运动方向推导 rot_y 时符号
搞反了)。在用条件性的 + Math.PI 翻转来打补丁之前,请直接从速度矢量重新推导
rot_y:rot_y = atan2(velocity.x, velocity.z),并移除所有按分支添加的符号
补丁——在错误公式上叠加条件翻转,通常表现为"修好一半",因为它补偿的是错误的
那个 bug。
phase 字段(0–1)为生成时片段的归一化时间偏移:
startTime = phase × clipDuration // 单位:秒
这样可避免整个人群动画同步。
能力目录(Capabilities)
GET /v1/capabilities(也在每个人群响应中通过 assets.capabilities_json 提供)是您用于构建请求和 UI 的目录。它列出可用的边界形状、可在 weights 中引用的桶 id/标签,以及每个部件的 id、标签、组别和性别。结构如下:
{
"shapes": ["circle", "square", "road", "donut", "spline", "polygon"],
"buckets": [
{ "id": "soldiers", "label": "Soldiers" },
{ "id": "laborers", "label": "Laborers" }
// ... 每个可加权的原型一个条目
],
"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" }
// ... 所有身体、面部和发型部件
]
}
完整示例
请求
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.* — 完整解析的 GLB URL,直接传给加载器
// crowd.characters[] — 每 NPC 的部件 ID、位置、动画片段、颜色
console.log(crowd.characters.length + ' 个角色');
console.log('骨架 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] 人群加载失败: {req.error}");
yield break;
}
// crowd.assets.skeleton_glb — 使用 GLTFast / Piglet / 自定义 GLB 加载器加载
// crowd.characters[] — 每 NPC 的部件 ID、位置、动画、颜色
string json = req.downloadHandler.text;
Debug.Log($"[Genji] 响应: {json.Length} 字节");
// 下一步:将 json 解析为 CrowdResponse 类并下载资源
}
}
响应 `200 OK`(缩略版——完整响应含 150 个角色)
{
"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 个角色
],
"shared_parts": ["Eye_L", "Eye_R", "L_Hand", "R_Hand", "L_Foot", "R_Foot"],
// 资源 URL 反映服务器当前运行位置——请从响应中读取
"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"
}
如何在引擎中渲染
1. GET /v1/crowd/{crowd_id} → 获取响应 R
2. 下载一次 R.assets.skeleton_glb。
其中包含每个部件对应的 SkinnedMesh 节点,所有节点绑定到同一共享骨架。
自检:加载后的角色身高约 1.7 米。请勿做任何单位换算(见上方"坐标系")。
3. 对 R.characters[] 中的每个角色 C:
a. 使用支持骨架的克隆方法克隆整个已加载场景
(three.js:THREE.SkeletonUtils.clone(gltf.scene))。
普通的 .clone(true) 会让克隆体仍绑定到原始骨架 ——
所有副本将同步变形或渲染在错误位置。
b. 选择网格节点:C.body_id、C.face_id、C.hair_id
+ R.shared_parts 中的所有节点
隐藏所有其他 SkinnedMesh 节点。
c. 放置角色 —— 变换只应用在克隆的最外层包装节点上:
position = (C.x, 0, C.z) ← Y 取 (x,z) 处的地形高度
rotation = Quaternion.fromAxisAngle(Y, C.rot_y)
scale = Vector3(C.scale, C.scale, C.scale) ← 仅为体型差异,约 0.95–1.05
注意:蒙皮网格跟随的是骨骼,不是自身节点变换。给单个 SkinnedMesh
设置 position/scale 不会有任何效果。请变换克隆调用返回的场景包装
节点,并且绝不要碰内部名为 "root" 的节点 —— 它携带烘焙的
0.01 厘米→米缩放,覆盖它会破坏角色。
d. 应用颜色(先为每个角色克隆材质,避免颜色互相串扰):
身体网格 → C.garment_hsl 是完整颜色:
material.color.setHSL(h, s, l)。无需查询基础色调。
面部 + 手 + 脚 → C.skin_hex
发型网格 → C.hair_hex
e. 选择动画:
if C.anim_clip == "idle":
clip = idle_animation[C.idle_variant] ← 见动画表
else:
clip = walk_animation
动画片段以 R.assets.rest_reference_glb 为基准制作 ——
播放前需重定向到骨架(见"动画片段")。
startTime = C.phase × clip.duration
f. 运行模拟:
使用 R.recipe 作为智能体系统的配置输入:
- boundary shape/radius → 限制区域
- collisionRadius → NPC 体积大小(用于避让)
- personalSpace → 向心力 / 路径跟随强度
- anticipation → 提前转向距离
- obstacles[] → 静态避让圆盘
- pace → 行走与待机角色比例
- walkSpeedMin/Max → 每 NPC 速度范围(× 片段速度)
常见集成故障(调试前先查此表)
| 症状 | 原因 | 解决方法 |
|---|---|---|
| 角色缩小约 100 倍 / 不可见 | 额外应用了 0.01 单位换算 | 移除 —— GLB 已是米制 |
| 角色放大约 100 倍 | 导入器丢弃了 GLB 根节点缩放 | 在根节点应用一次 0.01 |
| 角色沉入地面 / 设置位置无效 | 变换设置在了 SkinnedMesh 节点上 | 改为变换克隆的包装节点 |
| 所有克隆同步变形 / 集中在原点 | 使用了普通 .clone(true) | 改用 SkeletonUtils.clone |
| 身体与帽子/头发比例不一致 | 内部 "root" 节点的缩放被覆盖 | 绝不修改内部 "root" 节点 |
| 只有头部/帽子可见,没有身体 | 同上,或身体节点被误隐藏 | 检查 "root" 节点缩放和可见性列表 |
| 所有服装颜色相同 | 对共享材质实例着色 | 先为每个角色克隆材质再 setHSL |
| 服装颜色不对 | 把 garment_hsl 当作基础色调的偏移量 | 它是完整颜色 —— 直接应用 |
| 角色悬空或下沉 | 为掩盖缩放问题添加了 Y 偏移 | 移除偏移,修复根节点变换 |
数据量规模
v1 响应包含完整的 characters[] 数组,每个 NPC 对应一个条目。数据量随人群规模线性增长:
recipe.size |
近似响应大小 |
|---|---|
| 150(示例) | ~55 KB |
| 1 000 | ~360 KB |
| 5 000 | ~1.8 MB |
| 10 000 | ~3.6 MB |
对于数千角色以内的人群,在局域网或快速连接上表现良好。对于超大人群(10,000+),首次加载和解析完整列表可能增加明显延迟。
确定性
相同的 seed 和相同的请求参数始终返回相同的人群——相同的角色、位置和颜色,顺序也相同。您可以据此进行缓存和复现:按 crowd_id + seed(+ size)缓存响应并随意重新请求。生成在服务端运行;您只需消费已解析的 characters[] 数组并直接渲染。
认证(Authentication)
每个 /v1 请求都需要在 Bearer 令牌中提供您的 API 密钥:
Authorization: Bearer YOUR_API_KEY
请在 Genji 账户 → 密钥(Account → Keys) 页面创建和管理密钥。请妥善保密 —— 切勿提交到代码库或嵌入客户端代码中。
本地运行
# 1. 启动 API 服务器
npm run server
# 2. 写入示例数据(运行一次)
node --env-file=.env server/seed-api.js
# 3. 测试
curl -s \
-H "Authorization: Bearer YOUR_API_KEY" \
https://genji-api.ngrok.app/v1/crowd/cr_sample01
通过 ngrok 对外暴露以供远程测试(请勿自动运行):
ngrok http 3001
# 当前隧道:https://genji-api.ngrok.app
# 将 https://genji-api.ngrok.app/v1/crowd/cr_sample01 分享给集成团队。
# 启动服务器前设置 ASSET_BASE_URL=https://genji-api.ngrok.app。
Unity / Tuanjie 集成
控制分为两个层级。
第一层:设计阶段(加载前)
人群在 Genji 编辑器中创作并保存为配方(recipe)。工程团队调用 API,获取配方和角色列表,然后在 Unity / Tuanjie 中构建人群。如需修改形状、颜色、行走比例或障碍物,只需在 Genji 中编辑配方后重新请求即可。
角色克隆:使用 SkeletonUtils,而非 scene.clone()
所有角色共享同一个 GLB 文件。要在场景中放置多个角色,必须为每个角色单独克隆已加载的场景图。请勿使用 scene.clone(true) — 该方法虽然复制了节点,但克隆出的 SkinnedMesh 实例仍绑定到原始骨架的骨骼上,导致所有副本同步变形或完全不可见。
请使用 THREE.SkeletonUtils.clone()(Three.js)或引擎对应的深度克隆方法,确保每个克隆独立绑定骨架:
import { clone } from 'three/examples/jsm/utils/SkeletonUtils.js';
// gltf = GLTFLoader.load(assets.skeleton_glb, ...) 的加载结果
for (const C of characters) {
const root = clone(gltf.scene); // 正确用法 — 每个克隆独立绑定骨骼
// 隐藏所有网格,仅显示 C.body_id、C.face_id、C.hair_id 及 shared_parts
root.position.set(C.x, 0, C.z);
scene.add(root);
}
第二层:运行阶段(加载后)
API 提供的是初始状态。运行时,您的 Unity / Tuanjie 代码驱动整个模拟。配方字段作为配置参数输入到您自己的智能体系统中:
| 字段 | 控制内容 |
|---|---|
boundaryShape + boundaryRadius |
将角色限制在此区域内 |
pace |
行走与待机角色的比例 |
walkSpeedMin / walkSpeedMax |
每个角色的速度范围 |
collisionRadius |
角色间最小距离 |
personalSpace |
拥挤时的排斥力强度 |
anticipation |
提前转向的距离 |
obstacles[] |
需要绕行的静态障碍物 |
attractTarget |
角色聚集的目标点 |
您的引擎拥有完整的运行时控制权——可以逐帧覆盖上述任意参数、移动吸引点、动态添加障碍物或实时调整行走比例。API 提供的是初始设计值,运行时模拟完全由您的引擎驱动。
未来规划功能
- 运行时 API — 实时连接,支持在场景进行中更新人群配置,无需重新加载。
- 编辑器实时联动 — Genji 编辑器直接将配方变更推送到运行中的 Unity / Tuanjie 实例。
- 单角色覆盖 — 无需重新加载整个人群,即可替换单个角色的服装或位置。
当前模式:一次加载,自主模拟。